Skip to main content

pinget_core/
lib.rs

1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fmt::{Display, Formatter};
6use std::fs;
7use std::io::{Cursor, Read};
8#[cfg(windows)]
9use std::mem::{MaybeUninit, size_of};
10#[cfg(windows)]
11use std::os::windows::ffi::{OsStrExt, OsStringExt};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, OnceLock, RwLock};
14
15use anyhow::{Context, Result, anyhow, bail};
16use chrono::{DateTime, Duration, Utc};
17use reqwest::blocking::{Client, Response};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map as JsonMap, Value as JsonValue};
20use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
21use sha2::{Digest, Sha256};
22use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
23#[cfg(windows)]
24use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
25#[cfg(windows)]
26use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
27#[cfg(windows)]
28use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
29#[cfg(windows)]
30use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
31#[cfg(windows)]
32use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
33#[cfg(windows)]
34use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
35#[cfg(windows)]
36use winreg::{RegKey, enums::*};
37use zip::ZipArchive;
38
39const DEFAULT_MARKET: &str = "US";
40const DEFAULT_MAX_RESULTS: usize = 50;
41const LIST_LOOKUP_MAX_RESULTS: usize = 500;
42const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
43const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
44const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 5;
45const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
46#[cfg(windows)]
47const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
48#[cfg(windows)]
49const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
50const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
51    "Installed package discovery is not supported on this platform; returning no installed packages.";
52const INSTALL_UNSUPPORTED_WARNING: &str =
53    "Installing packages is not supported on this platform; no changes were made.";
54const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
55const REPAIR_REINSTALL_WARNING: &str =
56    "Pinget repair currently re-runs the package install flow for the selected package.";
57const UNINSTALL_UNSUPPORTED_WARNING: &str =
58    "Uninstalling packages is not supported on this platform; no changes were made.";
59#[cfg(windows)]
60const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
61const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
62    "LocalManifestFiles",
63    "BypassCertificatePinningForMicrosoftStore",
64    "InstallerHashOverride",
65    "LocalArchiveMalwareScanOverride",
66    "ProxyCommandLineOptions",
67];
68const REST_SUPPORTED_CONTRACTS: &[&str] = &[
69    "1.12.0", "1.10.0", "1.9.0", "1.7.0", "1.6.0", "1.5.0", "1.4.0", "1.1.0", "1.0.0",
70];
71
72#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
73pub enum SourceKind {
74    #[serde(rename = "PreIndexed", alias = "preIndexed")]
75    PreIndexed,
76    #[serde(rename = "Rest", alias = "rest")]
77    Rest,
78}
79
80impl Display for SourceKind {
81    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82        match self {
83            SourceKind::PreIndexed => f.write_str("preindexed"),
84            SourceKind::Rest => f.write_str("rest"),
85        }
86    }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SourceRecord {
91    pub name: String,
92    pub kind: SourceKind,
93    pub arg: String,
94    pub identifier: String,
95    #[serde(default = "default_source_trust_level")]
96    pub trust_level: String,
97    #[serde(default)]
98    pub explicit: bool,
99    #[serde(default)]
100    pub priority: i32,
101    #[serde(default)]
102    pub last_update: Option<DateTime<Utc>>,
103    #[serde(default)]
104    pub source_version: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108struct SourceStore {
109    sources: Vec<SourceRecord>,
110}
111
112impl Default for SourceStore {
113    fn default() -> Self {
114        Self {
115            sources: vec![
116                SourceRecord {
117                    name: "winget".to_owned(),
118                    kind: SourceKind::PreIndexed,
119                    arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
120                    identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
121                    trust_level: "Trusted".to_owned(),
122                    explicit: false,
123                    priority: 0,
124                    last_update: None,
125                    source_version: None,
126                },
127                SourceRecord {
128                    name: "msstore".to_owned(),
129                    kind: SourceKind::Rest,
130                    arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
131                    identifier: "StoreEdgeFD".to_owned(),
132                    trust_level: "Trusted".to_owned(),
133                    explicit: false,
134                    priority: 0,
135                    last_update: None,
136                    source_version: None,
137                },
138            ],
139        }
140    }
141}
142
143/// Options for embedding the WinGet core library in another application.
144#[derive(Debug, Clone)]
145pub struct RepositoryOptions {
146    pub app_root: PathBuf,
147    pub user_agent: String,
148    pub pre_indexed_source_auto_update_interval: Option<Duration>,
149}
150
151impl RepositoryOptions {
152    /// Creates library options that keep all persistent state under the supplied root.
153    pub fn new(app_root: impl Into<PathBuf>) -> Self {
154        Self {
155            app_root: app_root.into(),
156            user_agent: DEFAULT_USER_AGENT.to_owned(),
157            pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
158        }
159    }
160
161    /// Uses the default per-user app-data root that the CLI also uses.
162    pub fn for_current_user() -> Result<Self> {
163        Ok(Self::new(default_app_root()?))
164    }
165
166    /// Overrides the HTTP user-agent sent to source endpoints.
167    #[must_use]
168    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
169        self.user_agent = user_agent.into();
170        self
171    }
172
173    /// Overrides automatic freshness checks for existing pre-indexed source indexes.
174    #[must_use]
175    pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
176        self.pre_indexed_source_auto_update_interval = interval;
177        self
178    }
179}
180
181#[derive(Debug, Clone, Default)]
182pub struct PackageQuery {
183    pub query: Option<String>,
184    pub id: Option<String>,
185    pub name: Option<String>,
186    pub moniker: Option<String>,
187    pub tag: Option<String>,
188    pub command: Option<String>,
189    pub source: Option<String>,
190    pub count: Option<usize>,
191    pub exact: bool,
192    pub version: Option<String>,
193    pub channel: Option<String>,
194    pub locale: Option<String>,
195    pub installer_type: Option<String>,
196    pub installer_architecture: Option<String>,
197    pub platform: Option<String>,
198    pub os_version: Option<String>,
199    pub install_scope: Option<String>,
200}
201
202#[derive(Debug, Clone, Default)]
203pub struct ListQuery {
204    pub query: Option<String>,
205    pub id: Option<String>,
206    pub name: Option<String>,
207    pub moniker: Option<String>,
208    pub tag: Option<String>,
209    pub command: Option<String>,
210    pub product_code: Option<String>,
211    pub version: Option<String>,
212    pub source: Option<String>,
213    pub count: Option<usize>,
214    pub exact: bool,
215    pub install_scope: Option<String>,
216    pub upgrade_only: bool,
217    pub include_unknown: bool,
218    pub include_pinned: bool,
219}
220
221#[derive(Debug, Clone, serde::Serialize)]
222#[serde(rename_all = "PascalCase")]
223pub struct SearchMatch {
224    pub source_name: String,
225    pub source_kind: SourceKind,
226    pub id: String,
227    pub name: String,
228    pub moniker: Option<String>,
229    pub version: Option<String>,
230    pub channel: Option<String>,
231    pub match_criteria: Option<String>,
232}
233
234#[derive(Debug, Clone, serde::Serialize)]
235#[serde(rename_all = "PascalCase")]
236pub struct SearchResponse {
237    pub matches: Vec<SearchMatch>,
238    pub warnings: Vec<String>,
239    pub truncated: bool,
240}
241
242#[derive(Debug, Clone, serde::Serialize)]
243#[serde(rename_all = "PascalCase")]
244pub struct ListMatch {
245    pub name: String,
246    pub id: String,
247    pub local_id: String,
248    pub installed_version: String,
249    pub available_version: Option<String>,
250    pub source_name: Option<String>,
251    pub publisher: Option<String>,
252    pub scope: Option<String>,
253    pub installer_category: Option<String>,
254    pub install_location: Option<String>,
255    pub package_family_names: Vec<String>,
256    pub product_codes: Vec<String>,
257    pub upgrade_codes: Vec<String>,
258}
259
260#[derive(Debug, Clone, serde::Serialize)]
261#[serde(rename_all = "PascalCase")]
262pub struct ListResponse {
263    pub matches: Vec<ListMatch>,
264    pub warnings: Vec<String>,
265    pub truncated: bool,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
269#[serde(rename_all = "PascalCase")]
270pub struct VersionKey {
271    pub version: String,
272    pub channel: String,
273}
274
275#[derive(Debug, Clone, serde::Serialize)]
276pub struct Manifest {
277    pub id: String,
278    pub name: String,
279    pub version: String,
280    pub channel: String,
281    pub publisher: Option<String>,
282    pub description: Option<String>,
283    pub moniker: Option<String>,
284    pub package_url: Option<String>,
285    pub publisher_url: Option<String>,
286    pub publisher_support_url: Option<String>,
287    pub license: Option<String>,
288    pub license_url: Option<String>,
289    pub privacy_url: Option<String>,
290    pub author: Option<String>,
291    pub copyright: Option<String>,
292    pub copyright_url: Option<String>,
293    pub release_notes: Option<String>,
294    pub release_notes_url: Option<String>,
295    pub tags: Vec<String>,
296    pub agreements: Vec<PackageAgreement>,
297    pub package_dependencies: Vec<String>,
298    pub documentation: Vec<Documentation>,
299    pub installers: Vec<Installer>,
300    // `RequireExplicitUpgrade: true` opts a package out of bulk
301    // `pinget upgrade` output (winget parity). Users can still upgrade by
302    // explicit `id`. Set at top-level or per-installer; treated as true
303    // when any installer asserts it.
304    pub require_explicit_upgrade: bool,
305}
306
307#[derive(Debug, Clone, serde::Serialize)]
308pub struct Documentation {
309    pub label: Option<String>,
310    pub url: String,
311}
312
313#[derive(Debug, Clone, serde::Serialize)]
314pub struct PackageAgreement {
315    pub label: Option<String>,
316    pub text: Option<String>,
317    pub url: Option<String>,
318}
319
320#[derive(Debug, Clone, Default, serde::Serialize)]
321pub struct Installer {
322    pub architecture: Option<String>,
323    pub installer_type: Option<String>,
324    pub nested_installer_type: Option<String>,
325    pub nested_installer_files: Vec<NestedInstallerFile>,
326    pub url: Option<String>,
327    pub sha256: Option<String>,
328    pub product_code: Option<String>,
329    pub locale: Option<String>,
330    pub scope: Option<String>,
331    pub release_date: Option<String>,
332    pub package_family_name: Option<String>,
333    pub upgrade_code: Option<String>,
334    pub platforms: Vec<String>,
335    pub minimum_os_version: Option<String>,
336    pub switches: InstallerSwitches,
337    pub commands: Vec<String>,
338    pub package_dependencies: Vec<String>,
339    pub require_explicit_upgrade: bool,
340}
341
342#[derive(Debug, Clone, Default, serde::Serialize)]
343pub struct NestedInstallerFile {
344    /// Path to the executable within the extracted archive.
345    pub relative_file_path: String,
346    /// Optional alias winget exposes for portable invocation. Pinget records it
347    /// for parity and uses it during portable installation to create a
348    /// `Links\\<alias>.exe` symlink; `Links\\` may also be added to the user PATH.
349    pub portable_command_alias: Option<String>,
350}
351
352#[derive(Debug, Clone, Default, serde::Serialize)]
353pub struct InstallerSwitches {
354    pub silent: Option<String>,
355    pub silent_with_progress: Option<String>,
356    pub interactive: Option<String>,
357    pub custom: Option<String>,
358    pub log: Option<String>,
359    pub install_location: Option<String>,
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum InstallerMode {
364    Interactive,
365    SilentWithProgress,
366    Silent,
367}
368
369#[derive(Debug, Clone)]
370pub struct InstallRequest {
371    pub query: PackageQuery,
372    pub manifest_path: Option<PathBuf>,
373    pub mode: InstallerMode,
374    pub log_path: Option<PathBuf>,
375    pub custom: Option<String>,
376    pub override_args: Option<String>,
377    pub install_location: Option<String>,
378    pub skip_dependencies: bool,
379    pub dependencies_only: bool,
380    pub accept_package_agreements: bool,
381    pub force: bool,
382    pub rename: Option<String>,
383    pub uninstall_previous: bool,
384    pub ignore_security_hash: bool,
385    pub dependency_source: Option<String>,
386    pub no_upgrade: bool,
387}
388
389impl InstallRequest {
390    pub fn new(query: PackageQuery) -> Self {
391        Self {
392            query,
393            manifest_path: None,
394            mode: InstallerMode::SilentWithProgress,
395            log_path: None,
396            custom: None,
397            override_args: None,
398            install_location: None,
399            skip_dependencies: false,
400            dependencies_only: false,
401            accept_package_agreements: false,
402            force: false,
403            rename: None,
404            uninstall_previous: false,
405            ignore_security_hash: false,
406            dependency_source: None,
407            no_upgrade: false,
408        }
409    }
410}
411
412#[derive(Debug, Clone)]
413pub struct UninstallRequest {
414    pub query: PackageQuery,
415    pub manifest_path: Option<PathBuf>,
416    pub product_code: Option<String>,
417    pub mode: InstallerMode,
418    pub all_versions: bool,
419    pub force: bool,
420    pub purge: bool,
421    pub preserve: bool,
422    pub log_path: Option<PathBuf>,
423}
424
425impl UninstallRequest {
426    pub fn new(query: PackageQuery) -> Self {
427        Self {
428            query,
429            manifest_path: None,
430            product_code: None,
431            mode: InstallerMode::SilentWithProgress,
432            all_versions: false,
433            force: false,
434            purge: false,
435            preserve: false,
436            log_path: None,
437        }
438    }
439}
440
441#[derive(Debug, Clone)]
442pub struct RepairRequest {
443    pub query: PackageQuery,
444    pub manifest_path: Option<PathBuf>,
445    pub product_code: Option<String>,
446    pub mode: InstallerMode,
447    pub log_path: Option<PathBuf>,
448    pub accept_package_agreements: bool,
449    pub force: bool,
450    pub ignore_security_hash: bool,
451}
452
453impl RepairRequest {
454    pub fn new(query: PackageQuery) -> Self {
455        Self {
456            query,
457            manifest_path: None,
458            product_code: None,
459            mode: InstallerMode::SilentWithProgress,
460            log_path: None,
461            accept_package_agreements: false,
462            force: false,
463            ignore_security_hash: false,
464        }
465    }
466}
467
468#[derive(Debug, Clone, serde::Serialize)]
469pub struct ShowResult {
470    pub package: SearchMatch,
471    pub manifest: Manifest,
472    pub selected_installer: Option<Installer>,
473    pub cached_files: Vec<PathBuf>,
474    pub warnings: Vec<String>,
475    #[serde(skip_serializing)]
476    manifest_documents: JsonValue,
477}
478
479impl ShowResult {
480    pub fn structured_document(&self) -> JsonValue {
481        collapse_structured_document(&self.manifest_documents)
482    }
483}
484
485/// Result of a `download` operation. In addition to the resolved installer
486/// file on disk, pinget mirrors `winget download` by writing a flattened YAML
487/// manifest next to the installer so users get a self-contained snapshot of
488/// the package version they downloaded.
489#[derive(Debug, Clone, serde::Serialize)]
490pub struct DownloadOutput {
491    pub manifest: Manifest,
492    pub installer_path: PathBuf,
493    pub manifest_path: PathBuf,
494}
495
496/// Internal result of fetching only the installer file. Used by the install
497/// flow which downloads into a temp dir and intentionally skips the manifest
498/// YAML side effect.
499struct FetchedInstaller {
500    manifest: Manifest,
501    manifest_documents: JsonValue,
502    installer: Installer,
503    installer_path: PathBuf,
504}
505
506#[derive(Debug, Clone, serde::Serialize)]
507#[serde(rename_all = "PascalCase")]
508pub struct VersionsResult {
509    pub package: SearchMatch,
510    pub versions: Vec<VersionKey>,
511    pub warnings: Vec<String>,
512}
513
514#[derive(Debug, Clone, serde::Serialize)]
515#[serde(rename_all = "PascalCase")]
516pub struct CacheWarmResult {
517    pub package: SearchMatch,
518    pub cached_files: Vec<PathBuf>,
519    pub warnings: Vec<String>,
520}
521
522#[derive(Debug, Clone, serde::Serialize)]
523#[serde(rename_all = "PascalCase")]
524pub struct SourceUpdateResult {
525    pub name: String,
526    pub kind: SourceKind,
527    pub detail: String,
528}
529
530#[derive(Debug, Clone, serde::Serialize)]
531#[serde(rename_all = "PascalCase")]
532pub struct PinRecord {
533    pub package_id: String,
534    pub version: String,
535    pub source_id: String,
536    pub pin_type: PinType,
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
540pub enum PinType {
541    Pinning,
542    Blocking,
543    Gating,
544}
545
546impl Display for PinType {
547    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
548        match self {
549            PinType::Pinning => f.write_str("Pinning"),
550            PinType::Blocking => f.write_str("Blocking"),
551            PinType::Gating => f.write_str("Gating"),
552        }
553    }
554}
555
556/// Result of an install/uninstall/upgrade operation
557#[derive(Debug, Clone, serde::Serialize)]
558pub struct InstallResult {
559    pub package_id: String,
560    pub version: String,
561    pub installer_path: PathBuf,
562    pub installer_type: String,
563    pub exit_code: i32,
564    pub success: bool,
565    pub no_op: bool,
566    pub warnings: Vec<String>,
567}
568
569impl InstallerSwitches {
570    fn with_fallback(&self, fallback: &Self) -> Self {
571        Self {
572            silent: self.silent.clone().or_else(|| fallback.silent.clone()),
573            silent_with_progress: self
574                .silent_with_progress
575                .clone()
576                .or_else(|| fallback.silent_with_progress.clone()),
577            interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
578            custom: self.custom.clone().or_else(|| fallback.custom.clone()),
579            log: self.log.clone().or_else(|| fallback.log.clone()),
580            install_location: self
581                .install_location
582                .clone()
583                .or_else(|| fallback.install_location.clone()),
584        }
585    }
586}
587
588fn installed_package_discovery_supported() -> bool {
589    cfg!(windows)
590}
591
592fn package_actions_supported() -> bool {
593    cfg!(windows)
594}
595
596fn unsupported_action_result(
597    package_id: impl Into<String>,
598    version: impl Into<String>,
599    installer_type: impl Into<String>,
600    warning: &str,
601) -> InstallResult {
602    InstallResult {
603        package_id: package_id.into(),
604        version: version.into(),
605        installer_path: PathBuf::new(),
606        installer_type: installer_type.into(),
607        exit_code: 0,
608        success: true,
609        no_op: true,
610        warnings: vec![warning.to_owned()],
611    }
612}
613
614#[derive(Debug, Clone)]
615struct InstalledPackage {
616    name: String,
617    local_id: String,
618    installed_version: String,
619    publisher: Option<String>,
620    scope: Option<String>,
621    installer_category: Option<String>,
622    install_location: Option<String>,
623    package_family_names: Vec<String>,
624    product_codes: Vec<String>,
625    upgrade_codes: Vec<String>,
626    correlated: Option<SearchMatch>,
627    // True when `installed_version` was rewritten to a catalog Version via
628    // versionData.mszyml's aMiV/aMaV range. Such versions compare meaningfully
629    // against the catalog's Version; raw ARP DisplayVersions usually don't.
630    // Used by dedupe so that, when the same id has both a canonical and a raw
631    // install row, we prefer the canonical (e.g. `Microsoft.DotNet.SDK.10`
632    // 10.0.108 over the VS-installed 40.10.18029 that doesn't map).
633    installed_version_canonical: bool,
634    // True when the correlated catalog package's latest version sets
635    // `RequireExplicitUpgrade: true`. winget hides those rows from bulk
636    // `upgrade`; we mirror that. Users can still upgrade by explicit id.
637    correlated_requires_explicit_upgrade: bool,
638    // True when the correlated catalog package's latest version has no
639    // installer for an architecture the user can actually run. winget
640    // hides those from `upgrade`; we mirror that.
641    correlated_lacks_compatible_installer: bool,
642}
643
644#[derive(Debug, Clone)]
645struct LocatedMatch {
646    display: SearchMatch,
647    source_index: usize,
648    locator: MatchLocator,
649}
650
651#[derive(Debug)]
652struct SearchSourceMatches {
653    matches: Vec<LocatedMatch>,
654    truncated: bool,
655}
656
657#[derive(Debug, Clone)]
658enum MatchLocator {
659    PreIndexedV1 {
660        package_rowid: i64,
661    },
662    PreIndexedV2 {
663        package_rowid: i64,
664        package_hash: String,
665    },
666    Rest {
667        package_id: String,
668        versions: Vec<VersionKey>,
669    },
670}
671
672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
673enum SearchSemantics {
674    Many,
675    Single,
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize)]
679struct RestInfoCache {
680    expires_at: DateTime<Utc>,
681    value: RestInformation,
682}
683
684#[derive(Debug, Clone, Default, Serialize, Deserialize)]
685struct RestInformation {
686    #[serde(rename = "SourceIdentifier")]
687    source_identifier: String,
688    #[serde(rename = "ServerSupportedVersions", default)]
689    server_supported_versions: Vec<String>,
690    #[serde(rename = "RequiredPackageMatchFields", default)]
691    required_package_match_fields: Vec<String>,
692    #[serde(rename = "UnsupportedPackageMatchFields", default)]
693    unsupported_package_match_fields: Vec<String>,
694    #[serde(rename = "RequiredQueryParameters", default)]
695    required_query_parameters: Vec<String>,
696    #[serde(rename = "UnsupportedQueryParameters", default)]
697    unsupported_query_parameters: Vec<String>,
698}
699
700#[derive(Debug, Clone, Deserialize)]
701struct PackageVersionDataDocument {
702    #[serde(rename = "vD", default)]
703    versions: Vec<PackageVersionDataEntry>,
704}
705
706#[derive(Debug, Clone, Deserialize)]
707struct PackageVersionDataEntry {
708    #[serde(rename = "v")]
709    version: String,
710    #[serde(rename = "rP")]
711    manifest_relative_path: String,
712    #[serde(rename = "s256H")]
713    manifest_hash: String,
714    // ARP version range covered by this catalog version. WinGet writes these
715    // into versionData.mszyml so an installed ARP DisplayVersion (which may be
716    // an MSI build number with no relation to the catalog Version) can be
717    // mapped back to the catalog Version it corresponds to.
718    #[serde(rename = "aMiV", default)]
719    arp_min_version: Option<String>,
720    #[serde(rename = "aMaV", default)]
721    arp_max_version: Option<String>,
722}
723
724pub struct Repository {
725    app_root: PathBuf,
726    client: Client,
727    store: SourceStore,
728    use_system_winget_sources: bool,
729    pre_indexed_source_auto_update_interval: Option<Duration>,
730    preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
731}
732
733#[derive(Debug, Clone)]
734struct PreindexedRefreshAttempt {
735    attempted_at: DateTime<Utc>,
736    succeeded: bool,
737}
738
739#[derive(Debug, Clone, Default)]
740struct PreindexedRefreshOutcome {
741    attempted: bool,
742    reused_recent_attempt: bool,
743    error: Option<String>,
744}
745
746struct PreindexedOpenConnection {
747    connection: Connection,
748    refresh: PreindexedRefreshOutcome,
749}
750
751#[derive(Debug)]
752struct MissingPackageVersionError {
753    package_id: String,
754    source_name: String,
755    requested_version: String,
756    requested_channel: Option<String>,
757}
758
759impl Display for MissingPackageVersionError {
760    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
761        let channel = self
762            .requested_channel
763            .as_deref()
764            .filter(|value| !value.is_empty())
765            .map(|value| format!(" with channel '{value}'"))
766            .unwrap_or_default();
767        write!(
768            f,
769            "Version '{}'{} was not found for package '{}' in source '{}'.",
770            self.requested_version, channel, self.package_id, self.source_name
771        )
772    }
773}
774
775impl std::error::Error for MissingPackageVersionError {}
776
777fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
778    static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
779    LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
780}
781
782impl Repository {
783    /// Opens the repository with the default per-user CLI storage layout.
784    pub fn open() -> Result<Self> {
785        Self::open_with_options(RepositoryOptions::for_current_user()?)
786    }
787
788    /// Opens the repository with explicit hosting options for library consumers.
789    pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
790        ensure_app_dirs(&options.app_root)?;
791        let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
792        let store = load_store(&options.app_root)?;
793        let client = Client::builder()
794            .user_agent(&options.user_agent)
795            .build()
796            .context("failed to build HTTP client")?;
797        Ok(Self {
798            app_root: options.app_root,
799            client,
800            store,
801            use_system_winget_sources,
802            pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
803            preindexed_refresh_attempts: HashMap::new(),
804        })
805    }
806
807    pub fn app_root(&self) -> &Path {
808        &self.app_root
809    }
810
811    pub fn list_sources(&self) -> Vec<SourceRecord> {
812        self.store.sources.clone()
813    }
814
815    pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
816        self.add_source_with_metadata(name, arg, kind, None, false, 0)
817    }
818
819    pub fn add_source_with_metadata(
820        &mut self,
821        name: &str,
822        arg: &str,
823        kind: SourceKind,
824        trust_level: Option<&str>,
825        explicit: bool,
826        priority: i32,
827    ) -> Result<()> {
828        if self.use_system_winget_sources {
829            system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
830            self.refresh_system_winget_sources()?;
831            return Ok(());
832        }
833
834        if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
835            bail!("A source with name '{name}' already exists.");
836        }
837        if self.store.sources.iter().any(|s| s.arg == arg) {
838            bail!("A source with argument '{arg}' already exists.");
839        }
840        let identifier = name.to_owned();
841        self.store.sources.push(SourceRecord {
842            name: name.to_owned(),
843            kind,
844            arg: arg.to_owned(),
845            identifier,
846            trust_level: normalize_source_trust_level(trust_level)?,
847            explicit,
848            priority,
849            last_update: None,
850            source_version: None,
851        });
852        self.save_store()?;
853        Ok(())
854    }
855
856    pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
857        if self.use_system_winget_sources {
858            bail!("Editing system WinGet sources is not supported by winget source commands.");
859        }
860
861        let source = self
862            .store
863            .sources
864            .iter_mut()
865            .find(|source| source.name.eq_ignore_ascii_case(name))
866            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
867
868        if let Some(explicit) = explicit {
869            source.explicit = explicit;
870        }
871
872        if trust_level.is_some() {
873            source.trust_level = normalize_source_trust_level(trust_level)?;
874        }
875
876        self.save_store()?;
877        Ok(())
878    }
879
880    pub fn remove_source(&mut self, name: &str) -> Result<()> {
881        if self.use_system_winget_sources {
882            system_winget_remove_source(name)?;
883            self.refresh_system_winget_sources()?;
884            return Ok(());
885        }
886
887        let idx = self
888            .store
889            .sources
890            .iter()
891            .position(|s| s.name.eq_ignore_ascii_case(name))
892            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
893        let source = self.store.sources.remove(idx);
894        // Clean up cached state for this source
895        let state_dir = self.source_state_dir(&source);
896        if state_dir.exists() {
897            let _ = fs::remove_dir_all(&state_dir);
898        }
899        self.save_store()?;
900        Ok(())
901    }
902
903    pub fn reset_source(&mut self, name: &str) -> Result<()> {
904        if self.use_system_winget_sources {
905            system_winget_reset_source(name)?;
906            self.refresh_system_winget_sources()?;
907            return Ok(());
908        }
909
910        let idx = self
911            .store
912            .sources
913            .iter()
914            .position(|source| source.name.eq_ignore_ascii_case(name))
915            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
916
917        let source = self.store.sources[idx].clone();
918        let state_dir = self.source_state_dir(&source);
919        if state_dir.exists() {
920            let _ = fs::remove_dir_all(&state_dir);
921        }
922
923        if let Some(default_source) = SourceStore::default()
924            .sources
925            .into_iter()
926            .find(|source| source.name.eq_ignore_ascii_case(name))
927        {
928            self.store.sources[idx] = default_source;
929        } else {
930            self.store.sources[idx].last_update = None;
931            self.store.sources[idx].source_version = None;
932        }
933
934        self.save_store()?;
935        Ok(())
936    }
937
938    pub fn reset_sources(&mut self) -> Result<()> {
939        if self.use_system_winget_sources {
940            system_winget_reset_sources()?;
941            self.refresh_system_winget_sources()?;
942            return Ok(());
943        }
944
945        // Remove cached state for all current sources
946        for source in &self.store.sources {
947            let state_dir = self.source_state_dir(source);
948            if state_dir.exists() {
949                let _ = fs::remove_dir_all(&state_dir);
950            }
951        }
952        self.store = SourceStore::default();
953        self.save_store()?;
954        Ok(())
955    }
956
957    pub fn supported_admin_settings() -> &'static [&'static str] {
958        SUPPORTED_ADMIN_SETTINGS
959    }
960
961    pub fn get_user_settings(&self) -> Result<JsonValue> {
962        Ok(JsonValue::Object(load_json_object(&user_settings_path(
963            &self.app_root,
964        ))?))
965    }
966
967    pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
968        let update = match user_settings {
969            JsonValue::Object(object) => object.clone(),
970            _ => bail!("user settings must be a JSON object"),
971        };
972
973        let effective = if merge {
974            merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
975        } else {
976            update
977        };
978        save_json_object(user_settings_path(&self.app_root), &effective)?;
979        Ok(JsonValue::Object(effective))
980    }
981
982    pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
983        let current = self.get_user_settings()?;
984        Ok(if ignore_not_set {
985            json_contains(&current, expected)
986        } else {
987            current == *expected
988        })
989    }
990
991    pub fn get_admin_settings(&self) -> Result<JsonValue> {
992        let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
993        for name in SUPPORTED_ADMIN_SETTINGS {
994            settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
995        }
996
997        Ok(JsonValue::Object(settings))
998    }
999
1000    pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1001        let normalized = normalize_admin_setting_name(name)?;
1002        let mut settings = match self.get_admin_settings()? {
1003            JsonValue::Object(settings) => settings,
1004            _ => JsonMap::new(),
1005        };
1006        settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1007        save_json_object(admin_settings_path(&self.app_root), &settings)
1008    }
1009
1010    pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1011        if !reset_all && name.is_none() {
1012            bail!("resetting admin settings requires a setting name or reset-all");
1013        }
1014
1015        let mut settings = match self.get_admin_settings()? {
1016            JsonValue::Object(settings) => settings,
1017            _ => JsonMap::new(),
1018        };
1019
1020        if reset_all {
1021            for setting_name in SUPPORTED_ADMIN_SETTINGS {
1022                settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1023            }
1024        } else {
1025            settings.insert(
1026                normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1027                JsonValue::Bool(false),
1028            );
1029        }
1030
1031        save_json_object(admin_settings_path(&self.app_root), &settings)
1032    }
1033
1034    pub fn ensure_settings_files(&self) -> Result<()> {
1035        let user_settings = user_settings_path(&self.app_root);
1036        if !user_settings.exists() {
1037            save_json_object(user_settings, &JsonMap::new())?;
1038        }
1039
1040        let admin_settings = admin_settings_path(&self.app_root);
1041        if !admin_settings.exists() {
1042            let mut values = JsonMap::new();
1043            for name in SUPPORTED_ADMIN_SETTINGS {
1044                values.insert((*name).to_owned(), JsonValue::Bool(false));
1045            }
1046            save_json_object(admin_settings, &values)?;
1047        }
1048
1049        Ok(())
1050    }
1051
1052    pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1053        if self.use_system_winget_sources {
1054            return self.update_system_winget_sources(source_name);
1055        }
1056
1057        let indexes = self.resolve_source_indexes(source_name)?;
1058        let mut results = Vec::new();
1059
1060        for index in indexes {
1061            let detail = match self.store.sources[index].kind {
1062                SourceKind::PreIndexed => self.update_preindexed(index)?,
1063                SourceKind::Rest => self.update_rest(index)?,
1064            };
1065
1066            results.push(SourceUpdateResult {
1067                name: self.store.sources[index].name.clone(),
1068                kind: self.store.sources[index].kind,
1069                detail,
1070            });
1071        }
1072
1073        self.save_store()?;
1074        Ok(results)
1075    }
1076
1077    pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1078        let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
1079        Ok(SearchResponse {
1080            matches: matches.into_iter().map(|item| item.display).collect(),
1081            warnings,
1082            truncated,
1083        })
1084    }
1085
1086    pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1087        let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
1088        let mut structured_documents = Vec::with_capacity(matches.len());
1089        for located in matches {
1090            let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1091            structured_documents.push(manifest_documents);
1092        }
1093
1094        Ok(collapse_structured_documents(&structured_documents))
1095    }
1096
1097    pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1098        if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1099            bail!("--include-unknown and --include-pinned require --upgrade-available");
1100        }
1101        if query.source.is_some()
1102            && query.query.is_none()
1103            && query.id.is_none()
1104            && query.name.is_none()
1105            && query.moniker.is_none()
1106            && query.tag.is_none()
1107            && query.command.is_none()
1108        {
1109            bail!("list --source currently requires a query or explicit filter");
1110        }
1111
1112        let has_filter = list_query_needs_available_lookup(query);
1113        // Plain `list` should still correlate installed packages back to their
1114        // catalog identities so callers see canonical package ids/source names,
1115        // matching winget and the fixed C# Pinget behavior. `upgrade_only`
1116        // still adds the available-version specific filtering on top.
1117        let needs_available = true;
1118
1119        let mut warnings = Vec::new();
1120        if !installed_package_discovery_supported() {
1121            warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1122        }
1123        let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1124
1125        if needs_available {
1126            // Authoritative correlation via the v2 index's identity tables
1127            // (PackageFamilyName / ProductCode / UpgradeCode). This is winget's
1128            // primary path and resolves cases where display-name matching is
1129            // ambiguous (Microsoft.Teams vs Microsoft.Teams.Free) or impossible
1130            // (MSIX with `ms-resource:` placeholder names).
1131            warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1132
1133            // ARP entries that lack identity keys (no PFN/PC/UC) still match
1134            // winget's ARP correlation when their (DisplayName, Publisher),
1135            // run through the NameNormalizer, lands on a single package in
1136            // norm_names2 ∩ norm_publishers2. Covers Inno Setup-style
1137            // installers, MSIs whose ARP keys don't include ProductCode, and
1138            // any vendor that publishes a DisplayName that differs from the
1139            // catalog's PackageName but matches an AppsAndFeaturesEntries
1140            // DisplayName.
1141            warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1142        }
1143
1144        if needs_available && has_filter {
1145            // Filtered lookup: search sources with the user's query for any
1146            // installed package not already resolved by identity.
1147            let available_query = package_query_from_list_query(query);
1148            let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
1149            warnings.extend(source_warnings);
1150            let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1151            for package in &mut installed {
1152                if package.correlated.is_some() {
1153                    continue;
1154                }
1155                package.correlated =
1156                    correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1157            }
1158        } else if needs_available {
1159            // Unfiltered upgrade: look up each installed package by its correlation names
1160            warnings.extend(self.correlate_all_installed(&mut installed)?);
1161        }
1162
1163        if needs_available {
1164            // Enrich rows that correlated via the name-based fallback. Those
1165            // rows skipped the v2-index aMiV/aMaV pass that runs inside
1166            // `correlate_installed_via_index`, so they still carry the raw
1167            // ARP DisplayVersion (e.g. `JetBrains Rider 2025.3.0.1` showing
1168            // build `253.28294.112` instead of the marketing `2025.3.0.1`).
1169            // Also resolves MSIX placeholder names like
1170            // `ms-resource:appDisplayName` to the catalog's display name —
1171            // winget pulls these through Windows.Management.Deployment; the
1172            // catalog name is the parity-faithful substitute pinget has
1173            // available without taking a WinRT dependency.
1174            warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1175        }
1176
1177        // For `upgrade`, collapse multiple installed entries that map to the
1178        // same catalog package id (different ARP rows for side-by-side .NET
1179        // SDKs, several runtime versions of WindowsAppRuntime, the MSIX shim
1180        // alongside an ARP install of Edge, etc.). Keep the entry with the
1181        // highest installed_version so the upgrade comparison runs against
1182        // the user's newest install — matches winget's one-row-per-id output.
1183        if query.upgrade_only {
1184            installed = dedupe_correlated_for_upgrade(installed);
1185        }
1186
1187        let mut matches = installed
1188            .into_iter()
1189            .filter(|package| {
1190                list_package_matches(package, query)
1191                    && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1192            })
1193            .collect::<Vec<_>>();
1194
1195        matches.sort_by(|left, right| {
1196            list_sort_weight(left)
1197                .cmp(&list_sort_weight(right))
1198                .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1199                .then_with(|| left.local_id.cmp(&right.local_id))
1200        });
1201
1202        let pins = if query.upgrade_only {
1203            self.list_pins(query.source.as_deref())?
1204        } else {
1205            Vec::new()
1206        };
1207        let mut list_matches = matches
1208            .into_iter()
1209            .map(list_match_from_installed)
1210            .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1211            .collect::<Vec<_>>();
1212        let truncated = if let Some(limit) = query.count {
1213            let was_truncated = list_matches.len() > limit;
1214            list_matches.truncate(limit);
1215            was_truncated
1216        } else {
1217            false
1218        };
1219
1220        Ok(ListResponse {
1221            matches: list_matches,
1222            warnings,
1223            truncated,
1224        })
1225    }
1226
1227    /// For unfiltered upgrade/list, search the entire available index and correlate
1228    /// against all installed packages. Skips packages already correlated via the
1229    /// v2 identity tables.
1230    fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1231        if installed.iter().all(|p| p.correlated.is_some()) {
1232            return Ok(Vec::new());
1233        }
1234        let all_query = PackageQuery {
1235            query: None,
1236            id: None,
1237            name: None,
1238            moniker: None,
1239            tag: None,
1240            command: None,
1241            source: None,
1242            count: Some(100_000), // fetch the entire index
1243            exact: false,
1244            version: None,
1245            channel: None,
1246            locale: None,
1247            installer_type: None,
1248            installer_architecture: None,
1249            platform: None,
1250            os_version: None,
1251            install_scope: None,
1252        };
1253        let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
1254        let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1255
1256        for package in installed.iter_mut() {
1257            if package.correlated.is_some() {
1258                continue;
1259            }
1260            package.correlated = correlate_installed_package(package, &candidates, true);
1261        }
1262
1263        Ok(warnings)
1264    }
1265
1266    /// Correlates installed packages against the v2 pre-indexed catalog using
1267    /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's
1268    /// authoritative correlation path. Also rewrites `installed_version` to
1269    /// the catalog Version whose ARP range covers the ARP DisplayVersion, so
1270    /// packages whose ARP version is an MSI build number (e.g. .NET SDK,
1271    /// Python) compare against a meaningful catalog version. Packages without
1272    /// any of the three identifiers, or whose identifiers don't match a
1273    /// catalog entry, are left untouched for the name-based fallback.
1274    fn correlate_installed_via_index(
1275        &mut self,
1276        installed: &mut [InstalledPackage],
1277        requested_source: Option<&str>,
1278    ) -> Result<Vec<String>> {
1279        let source_indices: Vec<usize> = self
1280            .store
1281            .sources
1282            .iter()
1283            .enumerate()
1284            .filter(|(_, source)| {
1285                source.kind == SourceKind::PreIndexed
1286                    && match requested_source {
1287                        Some(name) => source.name.eq_ignore_ascii_case(name),
1288                        None => true,
1289                    }
1290            })
1291            .map(|(index, _)| index)
1292            .collect();
1293
1294        for source_index in source_indices {
1295            let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1296                let connection = match self.open_preindexed_connection(source_index) {
1297                    Ok(c) => c,
1298                    Err(_) => continue,
1299                };
1300                if !v2_identity_tables_present(&connection) {
1301                    continue;
1302                }
1303                let mut acc = Vec::new();
1304                for (idx, package) in installed.iter().enumerate() {
1305                    if package.correlated.is_some() {
1306                        continue;
1307                    }
1308                    let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1309                        continue;
1310                    };
1311                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1312                        continue;
1313                    };
1314                    acc.push((idx, meta, by));
1315                }
1316                acc
1317            };
1318
1319            let source = self.source_clone(source_index);
1320            for (idx, meta, by) in resolved {
1321                let (canonical_installed, anchored_latest) =
1322                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1323                        Ok((entries, _)) => (
1324                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1325                            latest_arp_anchored_version(&entries),
1326                        ),
1327                        Err(_) => (None, None),
1328                    };
1329                // When the installed side was rebased to a catalog Version via
1330                // aMiV/aMaV, the available side must compare on the same scale:
1331                // prefer the latest ARP-anchored Version over `latest_version`,
1332                // which can be an internal/MSI build number (e.g. WinAppRuntime
1333                // 1.8's `8000.836.2153.0`) that would manufacture a phantom
1334                // upgrade against an installed canonical like `1.8.6`.
1335                let available_version = match (&canonical_installed, anchored_latest) {
1336                    (Some(_), Some(anchored)) => anchored,
1337                    _ => meta.latest_version,
1338                };
1339                let installed_pkg = &mut installed[idx];
1340                installed_pkg.correlated = Some(SearchMatch {
1341                    source_name: source.name.clone(),
1342                    source_kind: source.kind,
1343                    id: meta.id,
1344                    name: meta.name,
1345                    moniker: meta.moniker,
1346                    version: Some(available_version),
1347                    channel: None,
1348                    match_criteria: Some(by.to_owned()),
1349                });
1350                if let Some(version) = canonical_installed {
1351                    installed_pkg.installed_version = version;
1352                    installed_pkg.installed_version_canonical = true;
1353                }
1354            }
1355        }
1356
1357        Ok(Vec::new())
1358    }
1359
1360    /// Mid-tier correlation that mirrors winget's ARP normalization path.
1361    /// For each installed package without a PFN/PC/UC identity match, the
1362    /// (DisplayName, Publisher) is run through the NameNormalizer and
1363    /// intersected against the v2 index's `norm_names2` and
1364    /// `norm_publishers2` tables. A single resulting `package_rowid`
1365    /// produces a correlation; ambiguous matches are skipped, matching
1366    /// winget's empty-Source behavior when normalization can't pick a
1367    /// winner.
1368    fn correlate_installed_by_normalized_identity(
1369        &mut self,
1370        installed: &mut [InstalledPackage],
1371        requested_source: Option<&str>,
1372    ) -> Result<Vec<String>> {
1373        let source_indices: Vec<usize> = self
1374            .store
1375            .sources
1376            .iter()
1377            .enumerate()
1378            .filter(|(_, source)| {
1379                source.kind == SourceKind::PreIndexed
1380                    && match requested_source {
1381                        Some(name) => source.name.eq_ignore_ascii_case(name),
1382                        None => true,
1383                    }
1384            })
1385            .map(|(index, _)| index)
1386            .collect();
1387
1388        for source_index in source_indices {
1389            let resolved: Vec<(usize, V2PackageMetadata)> = {
1390                let connection = match self.open_preindexed_connection(source_index) {
1391                    Ok(c) => c,
1392                    Err(_) => continue,
1393                };
1394                if !v2_normalized_identity_tables_present(&connection) {
1395                    continue;
1396                }
1397                let mut acc = Vec::new();
1398                for (idx, pkg) in installed.iter().enumerate() {
1399                    if pkg.correlated.is_some() {
1400                        continue;
1401                    }
1402                    // MSIX entries belong to the PFN-only path; skipping
1403                    // here mirrors `correlate_installed_package`'s
1404                    // hard-block on MSIX (their `ms-resource:` placeholder
1405                    // names normalize to garbage anyway).
1406                    if pkg.local_id.starts_with("MSIX\\") {
1407                        continue;
1408                    }
1409                    let Some(publisher) = pkg.publisher.as_deref() else {
1410                        continue;
1411                    };
1412                    let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1413                    if normalized_name.is_empty() {
1414                        continue;
1415                    }
1416                    let normalized_publisher = name_normalization::normalize_publisher(publisher);
1417                    if normalized_publisher.is_empty() {
1418                        continue;
1419                    }
1420                    let Some(rowid) =
1421                        lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1422                    else {
1423                        continue;
1424                    };
1425                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1426                        continue;
1427                    };
1428                    acc.push((idx, meta));
1429                }
1430                acc
1431            };
1432
1433            let source = self.source_clone(source_index);
1434            for (idx, meta) in resolved {
1435                let (canonical_installed, anchored_latest) =
1436                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1437                        Ok((entries, _)) => (
1438                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1439                            latest_arp_anchored_version(&entries),
1440                        ),
1441                        Err(_) => (None, None),
1442                    };
1443                let available_version = match (&canonical_installed, anchored_latest) {
1444                    (Some(_), Some(anchored)) => anchored,
1445                    _ => meta.latest_version,
1446                };
1447                let installed_pkg = &mut installed[idx];
1448                installed_pkg.correlated = Some(SearchMatch {
1449                    source_name: source.name.clone(),
1450                    source_kind: source.kind,
1451                    id: meta.id,
1452                    name: meta.name,
1453                    moniker: meta.moniker,
1454                    version: Some(available_version),
1455                    channel: None,
1456                    match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1457                });
1458                if let Some(version) = canonical_installed {
1459                    installed_pkg.installed_version = version;
1460                    installed_pkg.installed_version_canonical = true;
1461                }
1462            }
1463        }
1464
1465        Ok(Vec::new())
1466    }
1467
1468    /// Runs after both correlation paths to enrich already-correlated rows
1469    /// with information that the identity path applies inline but that
1470    /// name-based correlation skips:
1471    ///   • The MSIX-placeholder name fix — when an MSIX entry's installed
1472    ///     name is an unresolved Windows resource string (`ms-resource:...`)
1473    ///     winget renders the catalog's `PackageName` instead. We do the
1474    ///     same so display-name comparisons match.
1475    ///   • The aMiV/aMaV version remap — `JetBrains.Rider` has no
1476    ///     ProductCode/PFN/UpgradeCode so it correlates by name; without
1477    ///     this pass its installed version stays as the ARP build number
1478    ///     `253.28294.112` instead of the catalog Version `2025.3.0.1`.
1479    fn enrich_correlated_via_index(
1480        &mut self,
1481        installed: &mut [InstalledPackage],
1482        requested_source: Option<&str>,
1483    ) -> Result<Vec<String>> {
1484        // Cheap pass first — no SQL required.
1485        for package in installed.iter_mut() {
1486            apply_msix_resource_string_name_fix(package);
1487        }
1488
1489        // Expensive pass — load versionData for rows whose installed_version
1490        // wasn't remapped earlier.
1491        let source_indices: Vec<usize> = self
1492            .store
1493            .sources
1494            .iter()
1495            .enumerate()
1496            .filter(|(_, source)| {
1497                source.kind == SourceKind::PreIndexed
1498                    && match requested_source {
1499                        Some(name) => source.name.eq_ignore_ascii_case(name),
1500                        None => true,
1501                    }
1502            })
1503            .map(|(index, _)| index)
1504            .collect();
1505
1506        for source_index in source_indices {
1507            let needs: Vec<(usize, V2PackageMetadata)> = {
1508                let connection = match self.open_preindexed_connection(source_index) {
1509                    Ok(c) => c,
1510                    Err(_) => continue,
1511                };
1512                if !v2_identity_tables_present(&connection) {
1513                    continue;
1514                }
1515                let mut acc = Vec::new();
1516                for (idx, pkg) in installed.iter().enumerate() {
1517                    let Some(candidate) = pkg.correlated.as_ref() else {
1518                        continue;
1519                    };
1520                    // Stay within the source the row was correlated to —
1521                    // looking up `JetBrains.Rider` in a different catalog
1522                    // would either miss or pull the wrong package's range.
1523                    if !candidate
1524                        .source_name
1525                        .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1526                    {
1527                        continue;
1528                    }
1529                    let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1530                        continue;
1531                    };
1532                    acc.push((idx, meta));
1533                }
1534                acc
1535            };
1536
1537            let source = self.source_clone(source_index);
1538            for (idx, meta) in needs {
1539                // Load versionData once: we may need it for both the
1540                // aMiV/aMaV remap (only if installed_version isn't already
1541                // canonical) and to locate the latest manifest for the
1542                // RequireExplicitUpgrade flag.
1543                let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1544                    Ok((entries, _)) => entries,
1545                    Err(_) => continue,
1546                };
1547
1548                if !installed[idx].installed_version_canonical {
1549                    let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version);
1550                    let anchored_latest = latest_arp_anchored_version(&entries);
1551                    if let Some(canonical) = canonical_installed {
1552                        installed[idx].installed_version = canonical;
1553                        installed[idx].installed_version_canonical = true;
1554                        if let (Some(correlated), Some(anchored)) =
1555                            (installed[idx].correlated.as_mut(), anchored_latest)
1556                        {
1557                            correlated.version = Some(anchored);
1558                        }
1559                    }
1560                }
1561
1562                // RequireExplicitUpgrade is set on the installer manifest
1563                // for the latest catalog version. winget hides these from
1564                // bulk `upgrade` (Edge, Steam, Discord and others). We
1565                // peek at the first versionData entry (latest by sort
1566                // order) and read its manifest to mirror that filtering.
1567                if let Some(latest) = entries.first() {
1568                    let bytes_result = self.get_cached_source_file(
1569                        "V2_M",
1570                        &source,
1571                        &latest.manifest_relative_path,
1572                        Some(latest.manifest_hash.as_str()),
1573                    );
1574                    if let Ok(bytes) = bytes_result
1575                        && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1576                    {
1577                        installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1578                        installed[idx].correlated_lacks_compatible_installer =
1579                            !manifest_has_compatible_installer(&manifest);
1580                    }
1581                }
1582            }
1583        }
1584
1585        Ok(Vec::new())
1586    }
1587
1588    pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1589        let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1590        let versions = self.versions_for_match(&located, query)?;
1591        Ok(VersionsResult {
1592            package: located.display,
1593            versions,
1594            warnings,
1595        })
1596    }
1597
1598    pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1599        let (located, warnings) = self.find_single_match(query)?;
1600        let versions = self.versions_for_match(&located, query)?;
1601        Ok(VersionsResult {
1602            package: located.display,
1603            versions,
1604            warnings,
1605        })
1606    }
1607
1608    pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1609        let (located, warnings) = self.find_single_match(query)?;
1610        let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1611        let selected_installer = select_installer(&manifest.installers, query);
1612
1613        Ok(ShowResult {
1614            package: located.display,
1615            manifest,
1616            selected_installer,
1617            cached_files,
1618            warnings,
1619            manifest_documents,
1620        })
1621    }
1622
1623    pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1624        let (located, warnings) = self.find_single_match(query)?;
1625        let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1626
1627        Ok(CacheWarmResult {
1628            package: located.display,
1629            cached_files,
1630            warnings,
1631        })
1632    }
1633
1634    // ── Pin management ──
1635
1636    pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
1637        let db_path = pins_db_path(&self.app_root);
1638        if !db_path.exists() {
1639            return Ok(Vec::new());
1640        }
1641        let conn = Self::open_sqlite_connection(db_path)?;
1642        let pin_type_column = match resolve_pin_type_column(&conn)? {
1643            Some(column) => column,
1644            None => return Ok(Vec::new()),
1645        };
1646        let sql = if source_id.is_some() {
1647            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
1648        } else {
1649            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
1650        };
1651        let params = if let Some(source_id) = source_id {
1652            vec![SqlValue::Text(source_id.to_owned())]
1653        } else {
1654            Vec::new()
1655        };
1656        query_rows(&conn, &sql, params, |row| {
1657            let pin_type_int = row_i64(row, 3)?;
1658            Ok(PinRecord {
1659                package_id: row_string(row, 0)?,
1660                version: row_string(row, 1)?,
1661                source_id: row_string(row, 2)?,
1662                pin_type: decode_pin_type(pin_type_int),
1663            })
1664        })
1665    }
1666
1667    pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
1668        let db_path = pins_db_path(&self.app_root);
1669        let conn = Self::open_sqlite_connection(db_path)?;
1670        execute_batch_sql(
1671            &conn,
1672            "CREATE TABLE IF NOT EXISTS pin (
1673                package_id TEXT NOT NULL,
1674                version TEXT NOT NULL DEFAULT '*',
1675                source_id TEXT NOT NULL DEFAULT '',
1676                type INTEGER NOT NULL DEFAULT 0,
1677                PRIMARY KEY (package_id, source_id)
1678            )",
1679        )?;
1680        let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
1681        let type_int: i64 = match pin_type {
1682            PinType::Pinning => 2,
1683            PinType::Blocking => 4,
1684            PinType::Gating => 3,
1685        };
1686        execute_sql(
1687            &conn,
1688            &format!(
1689                "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
1690            ),
1691            vec![
1692                SqlValue::Text(package_id.to_owned()),
1693                SqlValue::Text(version.to_owned()),
1694                SqlValue::Text(source_id.to_owned()),
1695                SqlValue::Integer(type_int),
1696            ],
1697        )?;
1698        conn.cacheflush().context("failed to flush pin database")?;
1699        Ok(())
1700    }
1701
1702    pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
1703        let db_path = pins_db_path(&self.app_root);
1704        if !db_path.exists() {
1705            return Ok(false);
1706        }
1707        let conn = Self::open_sqlite_connection(db_path)?;
1708        let count = if let Some(source_id) = source_id {
1709            execute_sql(
1710                &conn,
1711                "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
1712                vec![
1713                    SqlValue::Text(package_id.to_owned()),
1714                    SqlValue::Text(source_id.to_owned()),
1715                ],
1716            )?
1717        } else {
1718            execute_sql(
1719                &conn,
1720                "DELETE FROM pin WHERE package_id = ?1",
1721                vec![SqlValue::Text(package_id.to_owned())],
1722            )?
1723        };
1724        conn.cacheflush().context("failed to flush pin database")?;
1725        Ok(count > 0)
1726    }
1727
1728    pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
1729        let db_path = pins_db_path(&self.app_root);
1730        if !db_path.exists() {
1731            return Ok(());
1732        }
1733
1734        if let Some(source_id) = source_id {
1735            let conn = Self::open_sqlite_connection(db_path)?;
1736            execute_sql(
1737                &conn,
1738                "DELETE FROM pin WHERE source_id = ?1",
1739                vec![SqlValue::Text(source_id.to_owned())],
1740            )?;
1741            conn.cacheflush().context("failed to flush pin database")?;
1742        } else {
1743            fs::remove_file(&db_path)?;
1744        }
1745        Ok(())
1746    }
1747
1748    fn save_store(&self) -> Result<()> {
1749        save_store(&self.app_root, &self.store)
1750    }
1751
1752    fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
1753        source_state_dir(&self.app_root, source)
1754    }
1755
1756    // ── Install / download ──
1757
1758    pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
1759        self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
1760    }
1761
1762    pub fn download_installer_for_request(
1763        &mut self,
1764        request: &InstallRequest,
1765        download_dir: &Path,
1766    ) -> Result<DownloadOutput> {
1767        let fetched = self.fetch_installer_for_request(request, download_dir)?;
1768        let manifest_yaml =
1769            build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
1770                .context("failed to render manifest YAML")?;
1771        let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
1772        let manifest_path = download_dir.join(&manifest_filename);
1773        fs::write(&manifest_path, manifest_yaml.as_bytes())
1774            .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
1775
1776        Ok(DownloadOutput {
1777            manifest: fetched.manifest,
1778            installer_path: fetched.installer_path,
1779            manifest_path,
1780        })
1781    }
1782
1783    /// Fetches the installer file without emitting the YAML manifest. Used by
1784    /// the install flow, which downloads into a temp dir and only needs the
1785    /// installer path — emitting a manifest there would litter the temp dir
1786    /// and turn a benign YAML-write failure into an installation failure.
1787    fn fetch_installer_for_request(
1788        &mut self,
1789        request: &InstallRequest,
1790        download_dir: &Path,
1791    ) -> Result<FetchedInstaller> {
1792        let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
1793        let installer = select_installer(&manifest.installers, &request.query)
1794            .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
1795        let url = installer
1796            .url
1797            .as_deref()
1798            .ok_or_else(|| anyhow!("Installer has no URL"))?;
1799
1800        fs::create_dir_all(download_dir)?;
1801        let filename = request.rename.as_deref().unwrap_or_else(|| {
1802            url.rsplit('/')
1803                .next()
1804                .unwrap_or("installer")
1805                .split('?')
1806                .next()
1807                .unwrap_or("installer")
1808        });
1809        let dest = download_dir.join(filename);
1810
1811        let response = self.client.get(url).send().context("failed to download installer")?;
1812        if !response.status().is_success() {
1813            bail!("Download failed: HTTP {}", response.status());
1814        }
1815        let bytes = response.bytes()?;
1816        fs::write(&dest, &bytes)?;
1817
1818        if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
1819            let _ = fs::remove_file(&dest);
1820            return Err(error);
1821        }
1822
1823        Ok(FetchedInstaller {
1824            manifest,
1825            manifest_documents,
1826            installer,
1827            installer_path: dest,
1828        })
1829    }
1830
1831    pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1832        let mut request = InstallRequest::new(query.clone());
1833        request.mode = if silent {
1834            InstallerMode::Silent
1835        } else {
1836            InstallerMode::SilentWithProgress
1837        };
1838        self.install_request(&request)
1839    }
1840
1841    pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
1842        let mut request = InstallRequest::new(query.clone());
1843        request.mode = mode;
1844        self.install_request(&request)
1845    }
1846
1847    pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
1848        let manifest = self.resolve_manifest_for_install(request)?;
1849        if !package_actions_supported() {
1850            let installer_type = select_installer(&manifest.installers, &request.query)
1851                .and_then(|installer| installer.installer_type)
1852                .unwrap_or_else(|| "install".to_owned())
1853                .to_lowercase();
1854            return Ok(unsupported_action_result(
1855                manifest.id.clone(),
1856                manifest.version.clone(),
1857                installer_type,
1858                INSTALL_UNSUPPORTED_WARNING,
1859            ));
1860        }
1861
1862        let existing_match = self.find_installed_package_for_install(request, &manifest)?;
1863        if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
1864            return Ok(no_op_result);
1865        }
1866
1867        Self::ensure_package_agreements_accepted(&manifest, request)?;
1868        self.install_dependencies(&manifest, request, &mut HashSet::new())?;
1869
1870        if request.dependencies_only {
1871            return Ok(InstallResult {
1872                package_id: manifest.id.clone(),
1873                version: manifest.version.clone(),
1874                installer_path: PathBuf::new(),
1875                installer_type: "dependencies".to_owned(),
1876                exit_code: 0,
1877                success: true,
1878                no_op: false,
1879                warnings: Vec::new(),
1880            });
1881        }
1882
1883        let installer = select_installer(&manifest.installers, &request.query)
1884            .ok_or_else(|| anyhow!("No applicable installer found"))?;
1885
1886        if request.uninstall_previous {
1887            let mut uninstall_request = UninstallRequest::new(PackageQuery {
1888                id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1889                query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
1890                source: request.query.source.clone(),
1891                exact: true,
1892                version: request.query.version.clone(),
1893                ..PackageQuery::default()
1894            });
1895            uninstall_request.product_code = installer.product_code.clone();
1896            uninstall_request.mode = InstallerMode::Silent;
1897            uninstall_request.all_versions = true;
1898            uninstall_request.force = true;
1899            let _ = self.uninstall_request(&uninstall_request);
1900        }
1901
1902        let temp_dir = std::env::temp_dir().join("pinget-install");
1903        let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
1904
1905        let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
1906
1907        let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
1908
1909        Ok(InstallResult {
1910            package_id: manifest.id.clone(),
1911            version: manifest.version.clone(),
1912            installer_path,
1913            installer_type,
1914            exit_code,
1915            success: exit_code == 0,
1916            no_op: false,
1917            warnings: Vec::new(),
1918        })
1919    }
1920
1921    fn find_installed_package_for_install(
1922        &mut self,
1923        request: &InstallRequest,
1924        manifest: &Manifest,
1925    ) -> Result<Option<ListMatch>> {
1926        let installed_matches = self.list(&ListQuery {
1927            query: request.query.query.clone(),
1928            id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1929            name: request.query.name.clone(),
1930            moniker: request.query.moniker.clone(),
1931            tag: None,
1932            command: None,
1933            product_code: None,
1934            version: None,
1935            source: request.query.source.clone(),
1936            count: Some(100),
1937            exact: request.query.exact || !manifest.id.trim().is_empty(),
1938            install_scope: request.query.install_scope.clone(),
1939            upgrade_only: false,
1940            include_unknown: false,
1941            include_pinned: false,
1942        })?;
1943
1944        Ok(installed_matches.matches.into_iter().find(|candidate| {
1945            candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
1946        }))
1947    }
1948
1949    fn create_install_no_op_result(
1950        request: &InstallRequest,
1951        manifest: &Manifest,
1952        existing_match: Option<&ListMatch>,
1953    ) -> Option<InstallResult> {
1954        let existing_match = existing_match?;
1955
1956        if request.no_upgrade {
1957            return Some(InstallResult {
1958                package_id: manifest.id.clone(),
1959                version: existing_match.installed_version.clone(),
1960                installer_path: PathBuf::new(),
1961                installer_type: "install".to_owned(),
1962                exit_code: 0,
1963                success: true,
1964                no_op: true,
1965                warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
1966            });
1967        }
1968
1969        if !request.force
1970            && !existing_match.installed_version.trim().is_empty()
1971            && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
1972        {
1973            return Some(InstallResult {
1974                package_id: manifest.id.clone(),
1975                version: existing_match.installed_version.clone(),
1976                installer_path: PathBuf::new(),
1977                installer_type: "install".to_owned(),
1978                exit_code: 0,
1979                success: true,
1980                no_op: true,
1981                warnings: vec![
1982                    "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
1983                ],
1984            });
1985        }
1986
1987        None
1988    }
1989
1990    pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
1991        if request.manifest_path.is_none() && !package_actions_supported() {
1992            let package_id = request
1993                .query
1994                .id
1995                .clone()
1996                .or_else(|| request.query.name.clone())
1997                .or_else(|| request.query.moniker.clone())
1998                .or_else(|| request.query.query.clone())
1999                .or_else(|| request.product_code.clone())
2000                .unwrap_or_else(|| "repair".to_owned());
2001            return Ok(unsupported_action_result(
2002                package_id,
2003                request.query.version.clone().unwrap_or_default(),
2004                "repair".to_owned(),
2005                REPAIR_UNSUPPORTED_WARNING,
2006            ));
2007        }
2008
2009        let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2010        let installed_match = if request.manifest_path.is_some() {
2011            None
2012        } else {
2013            let installed = self.list(&Self::create_repair_list_query(request))?;
2014            warnings.extend(installed.warnings);
2015            if installed.matches.is_empty() {
2016                bail!("No installed package matched the supplied repair query.");
2017            }
2018            if installed.matches.len() > 1 {
2019                bail!("Multiple installed packages matched the supplied repair query.");
2020            }
2021            installed.matches.into_iter().next()
2022        };
2023
2024        let mut result =
2025            self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2026        warnings.extend(result.warnings);
2027        result.warnings = warnings;
2028        Ok(result)
2029    }
2030
2031    pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2032        let mut request = UninstallRequest::new(query.clone());
2033        request.mode = if silent {
2034            InstallerMode::Silent
2035        } else {
2036            InstallerMode::SilentWithProgress
2037        };
2038        self.uninstall_request(&request)
2039    }
2040
2041    pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2042        if !package_actions_supported() {
2043            let (package_id, version) = if let Some(path) = &request.manifest_path {
2044                let manifest = Self::load_manifest_from_path(path)?;
2045                (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2046            } else {
2047                (
2048                    request
2049                        .query
2050                        .id
2051                        .clone()
2052                        .or_else(|| request.query.query.clone())
2053                        .or_else(|| request.query.name.clone())
2054                        .or_else(|| request.query.moniker.clone())
2055                        .or_else(|| request.product_code.clone())
2056                        .unwrap_or_else(|| "unknown".to_owned()),
2057                    request.query.version.clone().unwrap_or_default(),
2058                )
2059            };
2060
2061            return Ok(unsupported_action_result(
2062                package_id,
2063                version,
2064                "uninstall",
2065                UNINSTALL_UNSUPPORTED_WARNING,
2066            ));
2067        }
2068
2069        let matches = self.resolve_uninstall_matches(request)?;
2070        let mut exit_code = 0;
2071
2072        for installed in &matches {
2073            exit_code = uninstall_package(installed, request)?;
2074            if exit_code != 0 && !request.all_versions {
2075                break;
2076            }
2077        }
2078
2079        let installed = matches
2080            .first()
2081            .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2082
2083        Ok(InstallResult {
2084            package_id: installed.id.clone(),
2085            version: installed.installed_version.clone(),
2086            installer_path: PathBuf::new(),
2087            installer_type: "uninstall".to_owned(),
2088            exit_code,
2089            success: exit_code == 0,
2090            no_op: false,
2091            warnings: Vec::new(),
2092        })
2093    }
2094
2095    fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2096        self.resolve_manifest_and_documents_for_install(request)
2097            .map(|(manifest, _)| manifest)
2098    }
2099
2100    fn resolve_manifest_and_documents_for_install(
2101        &mut self,
2102        request: &InstallRequest,
2103    ) -> Result<(Manifest, JsonValue)> {
2104        if let Some(path) = &request.manifest_path {
2105            return Self::load_manifest_from_path_with_documents(path);
2106        }
2107
2108        let (located, _warnings) = self.find_single_match(&request.query)?;
2109        let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2110        Ok((manifest, documents))
2111    }
2112
2113    fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2114        if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2115            bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2116        }
2117        Ok(())
2118    }
2119
2120    fn install_dependencies(
2121        &mut self,
2122        manifest: &Manifest,
2123        request: &InstallRequest,
2124        visited: &mut HashSet<String>,
2125    ) -> Result<()> {
2126        if request.skip_dependencies {
2127            return Ok(());
2128        }
2129
2130        let dependency_ids = manifest
2131            .package_dependencies
2132            .iter()
2133            .chain(
2134                manifest
2135                    .installers
2136                    .iter()
2137                    .flat_map(|installer| installer.package_dependencies.iter()),
2138            )
2139            .filter(|value| !value.trim().is_empty())
2140            .cloned()
2141            .collect::<BTreeSet<_>>();
2142
2143        for dependency_id in dependency_ids {
2144            if !visited.insert(dependency_id.clone()) {
2145                continue;
2146            }
2147
2148            let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2149            self.install_request(&dependency_request)?;
2150        }
2151
2152        Ok(())
2153    }
2154
2155    fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2156        let mut dependency_request = InstallRequest::new(PackageQuery {
2157            id: Some(dependency_id),
2158            source: request
2159                .dependency_source
2160                .clone()
2161                .or_else(|| request.query.source.clone()),
2162            exact: true,
2163            ..PackageQuery::default()
2164        });
2165        dependency_request.mode = match request.mode {
2166            InstallerMode::Interactive => InstallerMode::Interactive,
2167            InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2168        };
2169        dependency_request.accept_package_agreements = request.accept_package_agreements;
2170        dependency_request.no_upgrade = true;
2171        dependency_request.ignore_security_hash = request.ignore_security_hash;
2172        dependency_request.dependency_source = request.dependency_source.clone();
2173        dependency_request
2174    }
2175
2176    fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2177        ListQuery {
2178            query: request.query.query.clone(),
2179            id: request.query.id.clone(),
2180            name: request.query.name.clone(),
2181            moniker: request.query.moniker.clone(),
2182            product_code: request.product_code.clone(),
2183            version: request.query.version.clone(),
2184            source: request.query.source.clone(),
2185            count: Some(100),
2186            exact: request.query.exact,
2187            install_scope: request.query.install_scope.clone(),
2188            ..ListQuery::default()
2189        }
2190    }
2191
2192    fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2193        let mut query = request.query.clone();
2194        query.query = if installed_match.is_none() {
2195            request.query.query.clone()
2196        } else {
2197            None
2198        };
2199        query.id = installed_match
2200            .map(|item| item.id.clone())
2201            .or_else(|| request.query.id.clone());
2202        query.name = if installed_match.is_none() {
2203            request.query.name.clone()
2204        } else {
2205            None
2206        };
2207        query.moniker = if installed_match.is_none() {
2208            request.query.moniker.clone()
2209        } else {
2210            None
2211        };
2212        query.source = request
2213            .query
2214            .source
2215            .clone()
2216            .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2217        query.exact = true;
2218        query.version = request
2219            .query
2220            .version
2221            .clone()
2222            .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2223
2224        InstallRequest {
2225            query,
2226            manifest_path: request.manifest_path.clone(),
2227            mode: request.mode,
2228            log_path: request.log_path.clone(),
2229            custom: None,
2230            override_args: None,
2231            install_location: None,
2232            skip_dependencies: false,
2233            dependencies_only: false,
2234            accept_package_agreements: request.accept_package_agreements,
2235            force: true,
2236            rename: None,
2237            uninstall_previous: false,
2238            ignore_security_hash: request.ignore_security_hash,
2239            dependency_source: None,
2240            no_upgrade: false,
2241        }
2242    }
2243
2244    fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2245        let mut effective_query = request.query.clone();
2246        let mut effective_product_code = request.product_code.clone();
2247
2248        if let Some(path) = &request.manifest_path {
2249            let manifest = Self::load_manifest_from_path(path)?;
2250            effective_query = PackageQuery {
2251                query: Some(manifest.id.clone()),
2252                id: Some(manifest.id.clone()),
2253                name: Some(manifest.name.clone()),
2254                exact: true,
2255                version: effective_query.version.clone().or(Some(manifest.version)),
2256                ..PackageQuery::default()
2257            };
2258            effective_product_code = effective_product_code.or_else(|| {
2259                manifest
2260                    .installers
2261                    .iter()
2262                    .find_map(|installer| installer.product_code.clone())
2263            });
2264        }
2265
2266        let list_query = ListQuery {
2267            query: effective_query.query.clone(),
2268            id: effective_query.id.clone(),
2269            name: effective_query.name.clone(),
2270            moniker: effective_query.moniker.clone(),
2271            tag: None,
2272            command: None,
2273            product_code: effective_product_code,
2274            version: effective_query.version.clone(),
2275            source: effective_query.source.clone(),
2276            count: if request.all_versions { None } else { Some(100) },
2277            exact: effective_query.exact,
2278            install_scope: effective_query.install_scope,
2279            upgrade_only: false,
2280            include_unknown: false,
2281            include_pinned: false,
2282        };
2283
2284        let list_result = self.list(&list_query)?;
2285        if list_result.matches.is_empty() {
2286            bail!("No installed package found matching the query");
2287        }
2288        if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2289            bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2290        }
2291
2292        Ok(if request.all_versions {
2293            list_result.matches
2294        } else {
2295            vec![list_result.matches[0].clone()]
2296        })
2297    }
2298
2299    fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2300        Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2301    }
2302
2303    fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2304        let resolved = resolve_manifest_path(manifest_path)?;
2305        let bytes =
2306            fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2307        parse_yaml_manifest_bundle(&bytes)
2308    }
2309
2310    fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2311        self.find_single_match_with_semantics(query, SearchSemantics::Single)
2312    }
2313
2314    fn find_single_match_with_semantics(
2315        &mut self,
2316        query: &PackageQuery,
2317        semantics: SearchSemantics,
2318    ) -> Result<(LocatedMatch, Vec<String>)> {
2319        let (matches, warnings, _) = self.search_located(query, semantics)?;
2320
2321        if matches.is_empty() {
2322            bail!("no package matched the supplied query");
2323        }
2324
2325        if matches.len() > 1 {
2326            let choices = matches
2327                .iter()
2328                .take(10)
2329                .map(|item| {
2330                    format!(
2331                        "{} [{}] ({})",
2332                        item.display.name, item.display.id, item.display.source_name
2333                    )
2334                })
2335                .collect::<Vec<_>>()
2336                .join(", ");
2337            bail!("multiple packages matched: {choices}");
2338        }
2339
2340        Ok((matches.into_iter().next().expect("one match"), warnings))
2341    }
2342
2343    fn search_located(
2344        &mut self,
2345        query: &PackageQuery,
2346        semantics: SearchSemantics,
2347    ) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
2348        let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2349        let mut matches = Vec::new();
2350        let mut warnings = Vec::new();
2351        let mut truncated = false;
2352
2353        for index in indexes {
2354            match self.search_source(index, query, semantics) {
2355                Ok(mut source_matches) => {
2356                    truncated |= source_matches.truncated;
2357                    matches.append(&mut source_matches.matches);
2358                }
2359                Err(_error) => warnings.push(format!(
2360                    "Failed when searching source; results will not be included: {}",
2361                    self.store.sources[index].name
2362                )),
2363            }
2364        }
2365
2366        if semantics == SearchSemantics::Many {
2367            matches.sort_by(|left, right| {
2368                search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2369            });
2370            let limit = max_results(query);
2371            if matches.len() > limit {
2372                truncated = true;
2373                matches.truncate(limit);
2374            }
2375        }
2376
2377        Ok((matches, warnings, truncated))
2378    }
2379
2380    fn search_source(
2381        &mut self,
2382        source_index: usize,
2383        query: &PackageQuery,
2384        semantics: SearchSemantics,
2385    ) -> Result<SearchSourceMatches> {
2386        match self.store.sources[source_index].kind {
2387            SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2388            SourceKind::Rest => self.search_rest(source_index, query, semantics),
2389        }
2390    }
2391
2392    fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2393        match &located.locator {
2394            MatchLocator::PreIndexedV1 { package_rowid } => {
2395                let source = self.source_clone(located.source_index);
2396                let connection = self.open_preindexed_connection(located.source_index)?;
2397                let versions = query_v1_versions(&connection, *package_rowid)?;
2398                let mut keys = versions
2399                    .into_iter()
2400                    .map(|row| VersionKey {
2401                        version: row.version,
2402                        channel: row.channel,
2403                    })
2404                    .collect::<Vec<_>>();
2405                sort_versions_desc(&mut keys);
2406                if keys.is_empty() {
2407                    bail!("no versions found for {} in {}", located.display.id, source.name);
2408                }
2409                Ok(keys)
2410            }
2411            MatchLocator::PreIndexedV2 {
2412                package_rowid,
2413                package_hash,
2414            } => {
2415                let source = self.source_clone(located.source_index);
2416                let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2417                let mut keys = entries
2418                    .into_iter()
2419                    .map(|entry| VersionKey {
2420                        version: entry.version,
2421                        channel: String::new(),
2422                    })
2423                    .collect::<Vec<_>>();
2424                sort_versions_desc(&mut keys);
2425                Ok(keys)
2426            }
2427            MatchLocator::Rest { versions, .. } => {
2428                let mut keys = versions.clone();
2429                sort_versions_desc(&mut keys);
2430                Ok(keys)
2431            }
2432        }
2433    }
2434
2435    fn manifest_for_match(
2436        &mut self,
2437        located: &LocatedMatch,
2438        query: &PackageQuery,
2439    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2440        self.manifest_for_match_with_refresh(located, query, true)
2441    }
2442
2443    fn manifest_for_match_with_refresh(
2444        &mut self,
2445        located: &LocatedMatch,
2446        query: &PackageQuery,
2447        allow_explicit_version_refresh: bool,
2448    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2449        match &located.locator {
2450            MatchLocator::PreIndexedV1 { package_rowid } => {
2451                let source = self.source_clone(located.source_index);
2452                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2453                let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2454                let selected = match select_v1_version(
2455                    &versions,
2456                    query.version.as_deref(),
2457                    query.channel.as_deref(),
2458                    &located.display.id,
2459                    &source.name,
2460                ) {
2461                    Ok(selected) => selected,
2462                    Err(error)
2463                        if allow_explicit_version_refresh
2464                            && query.version.is_some()
2465                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2466                    {
2467                        let open_refresh = opened.refresh.clone();
2468                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2469                            return missing_error_after_open_refresh(error, &open_refresh);
2470                        }
2471                        if self.has_recent_successful_preindexed_refresh_attempt(
2472                            &self.preindexed_refresh_lock_key(&source),
2473                        ) {
2474                            return Err(error);
2475                        }
2476                        drop(opened);
2477                        return self.refresh_preindexed_and_retry_manifest(
2478                            located.source_index,
2479                            &located.display,
2480                            query,
2481                            error,
2482                        );
2483                    }
2484                    Err(error) => return Err(error),
2485                };
2486                let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2487                let bytes =
2488                    self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2489                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2490                manifest.version = selected.version.clone();
2491                manifest.channel = selected.channel;
2492                Ok((manifest, manifest_documents, vec![bytes.path]))
2493            }
2494            MatchLocator::PreIndexedV2 {
2495                package_rowid,
2496                package_hash,
2497            } => {
2498                let source = self.source_clone(located.source_index);
2499                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2500                let (entries, version_data_file) =
2501                    self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2502                let selected = match select_v2_version(
2503                    &entries,
2504                    query.version.as_deref(),
2505                    query.channel.as_deref(),
2506                    &located.display.id,
2507                    &source.name,
2508                ) {
2509                    Ok(selected) => selected,
2510                    Err(error)
2511                        if allow_explicit_version_refresh
2512                            && query.version.is_some()
2513                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2514                    {
2515                        let open_refresh = opened.refresh.clone();
2516                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2517                            return missing_error_after_open_refresh(error, &open_refresh);
2518                        }
2519                        if self.has_recent_successful_preindexed_refresh_attempt(
2520                            &self.preindexed_refresh_lock_key(&source),
2521                        ) {
2522                            return Err(error);
2523                        }
2524                        drop(opened);
2525                        return self.refresh_preindexed_and_retry_manifest(
2526                            located.source_index,
2527                            &located.display,
2528                            query,
2529                            error,
2530                        );
2531                    }
2532                    Err(error) => return Err(error),
2533                };
2534                let manifest_bytes = self.get_cached_source_file(
2535                    "V2_M",
2536                    &source,
2537                    &selected.manifest_relative_path,
2538                    Some(selected.manifest_hash.as_str()),
2539                )?;
2540                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2541                manifest.version = selected.version;
2542                Ok((
2543                    manifest,
2544                    manifest_documents,
2545                    vec![version_data_file, manifest_bytes.path],
2546                ))
2547            }
2548            MatchLocator::Rest { package_id, versions } => {
2549                let source = self.source_clone(located.source_index);
2550                let selected = select_rest_version(
2551                    versions,
2552                    query.version.as_deref(),
2553                    query.channel.as_deref(),
2554                    &located.display.id,
2555                    &source.name,
2556                )?;
2557                let (bytes, cache_path) =
2558                    self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2559                let (manifest, manifest_documents) =
2560                    parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2561                Ok((manifest, manifest_documents, vec![cache_path]))
2562            }
2563        }
2564    }
2565
2566    fn refresh_preindexed_and_retry_manifest(
2567        &mut self,
2568        source_index: usize,
2569        previous_display: &SearchMatch,
2570        query: &PackageQuery,
2571        missing_error: anyhow::Error,
2572    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2573        self.refresh_preindexed_source(source_index, true)
2574            .with_context(|| format!("{missing_error} The source refresh also failed"))?;
2575        let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
2576        self.manifest_for_match_with_refresh(&refreshed, query, false)
2577    }
2578
2579    fn find_refreshed_preindexed_match(
2580        &mut self,
2581        source_index: usize,
2582        previous_display: &SearchMatch,
2583        query: &PackageQuery,
2584    ) -> Result<LocatedMatch> {
2585        let source_name = self.store.sources[source_index].name.clone();
2586        let mut source_query = query.clone();
2587        source_query.source = Some(source_name.clone());
2588        let matches = self
2589            .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
2590            .matches;
2591        if let Some(found) = matches
2592            .iter()
2593            .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
2594        {
2595            return Ok(found.clone());
2596        }
2597        if matches.len() == 1 {
2598            return Ok(matches.into_iter().next().expect("single match"));
2599        }
2600        bail!(
2601            "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
2602            previous_display.id,
2603            source_name
2604        )
2605    }
2606
2607    fn search_preindexed(
2608        &mut self,
2609        source_index: usize,
2610        query: &PackageQuery,
2611        semantics: SearchSemantics,
2612    ) -> Result<SearchSourceMatches> {
2613        let connection = self.open_preindexed_connection(source_index)?;
2614        let source = self.source_clone(source_index);
2615
2616        match query_v2_matches(&connection, query, semantics) {
2617            Ok((rows, truncated)) => Ok(SearchSourceMatches {
2618                truncated,
2619                matches: rows
2620                    .into_iter()
2621                    .map(|row| LocatedMatch {
2622                        display: SearchMatch {
2623                            source_name: source.name.clone(),
2624                            source_kind: source.kind,
2625                            id: row.id.clone(),
2626                            name: row.name.clone(),
2627                            moniker: row.moniker.clone(),
2628                            version: Some(row.version.clone()),
2629                            channel: None,
2630                            match_criteria: row.match_criteria.clone(),
2631                        },
2632                        source_index,
2633                        locator: MatchLocator::PreIndexedV2 {
2634                            package_rowid: row.package_rowid,
2635                            package_hash: row.package_hash,
2636                        },
2637                    })
2638                    .collect(),
2639            }),
2640            Err(error) if can_fallback_to_v1(&error) => {
2641                let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
2642                let grouped = group_v1_rows(rows);
2643
2644                Ok(SearchSourceMatches {
2645                    truncated,
2646                    matches: grouped
2647                        .into_iter()
2648                        .map(|row| LocatedMatch {
2649                            display: SearchMatch {
2650                                source_name: source.name.clone(),
2651                                source_kind: source.kind,
2652                                id: row.id.clone(),
2653                                name: row.name.clone(),
2654                                moniker: row.moniker.clone(),
2655                                version: Some(row.version.clone()),
2656                                channel: Some(row.channel.clone()),
2657                                match_criteria: row.match_criteria.clone(),
2658                            },
2659                            source_index,
2660                            locator: MatchLocator::PreIndexedV1 {
2661                                package_rowid: row.package_rowid,
2662                            },
2663                        })
2664                        .collect(),
2665                })
2666            }
2667            Err(error) => Err(error),
2668        }
2669    }
2670
2671    fn search_rest(
2672        &mut self,
2673        source_index: usize,
2674        query: &PackageQuery,
2675        semantics: SearchSemantics,
2676    ) -> Result<SearchSourceMatches> {
2677        let source = self.source_clone(source_index);
2678        let info = self.load_rest_information(source_index)?;
2679        let contract = choose_contract(&info.server_supported_versions)
2680            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2681
2682        let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
2683        let body = build_rest_search_body(query, &info, semantics)?;
2684        let response = self
2685            .client
2686            .post(url)
2687            .header("Version", contract)
2688            .json(&body)
2689            .send()
2690            .context("REST search request failed")?
2691            .error_for_status()
2692            .context("REST search request returned an error")?;
2693        let json = response
2694            .json::<JsonValue>()
2695            .context("failed to parse REST search response")?;
2696        let data = json
2697            .get("Data")
2698            .and_then(JsonValue::as_array)
2699            .cloned()
2700            .unwrap_or_default();
2701        let max_results = source_fetch_results(query, semantics);
2702
2703        let mut results = Vec::new();
2704        for item in data {
2705            let package_id = json_string(&item, "PackageIdentifier")
2706                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
2707            let package_name =
2708                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
2709            let mut versions = parse_rest_versions(&item)?;
2710            sort_versions_desc(&mut versions);
2711            let latest = versions
2712                .first()
2713                .cloned()
2714                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
2715
2716            results.push(LocatedMatch {
2717                display: SearchMatch {
2718                    source_name: source.name.clone(),
2719                    source_kind: source.kind,
2720                    id: package_id.clone(),
2721                    name: package_name,
2722                    moniker: json_string(&item, "Moniker"),
2723                    version: Some(latest.version.clone()),
2724                    channel: if latest.channel.is_empty() {
2725                        None
2726                    } else {
2727                        Some(latest.channel.clone())
2728                    },
2729                    match_criteria: rest_match_criteria(&item, query, semantics),
2730                },
2731                source_index,
2732                locator: MatchLocator::Rest { package_id, versions },
2733            });
2734        }
2735
2736        Ok(SearchSourceMatches {
2737            truncated: results.len() >= max_results,
2738            matches: results,
2739        })
2740    }
2741
2742    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
2743        Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
2744    }
2745
2746    fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
2747        let source = self.source_clone(source_index);
2748        let index_path = preindexed_index_path(&self.app_root, &source);
2749        let mut refresh = PreindexedRefreshOutcome::default();
2750        if !index_path.exists() {
2751            let _ = self.refresh_preindexed_source(source_index, true)?;
2752            refresh.attempted = true;
2753        } else if self.is_preindexed_index_stale(&source, &index_path) {
2754            let lock_key = self.preindexed_refresh_lock_key(&source);
2755            if self.has_recent_preindexed_refresh_attempt(&lock_key) {
2756                refresh.attempted = true;
2757                refresh.reused_recent_attempt = true;
2758            } else {
2759                refresh.attempted = true;
2760                if let Err(error) = self.refresh_preindexed_source(source_index, false) {
2761                    refresh.error = Some(format!("{error:#}"));
2762                }
2763            }
2764        }
2765
2766        Ok(PreindexedOpenConnection {
2767            connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
2768            refresh,
2769        })
2770    }
2771
2772    fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
2773        let Some(interval) = self.pre_indexed_source_auto_update_interval else {
2774            return false;
2775        };
2776
2777        let last_update = source.last_update.or_else(|| {
2778            fs::metadata(index_path)
2779                .ok()
2780                .and_then(|metadata| metadata.modified().ok())
2781                .map(DateTime::<Utc>::from)
2782        });
2783
2784        match last_update {
2785            Some(last_update) => Utc::now() - last_update >= interval,
2786            None => true,
2787        }
2788    }
2789
2790    fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
2791        format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
2792    }
2793
2794    fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
2795        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
2796            Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
2797        })
2798    }
2799
2800    fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
2801        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
2802            attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
2803        })
2804    }
2805
2806    fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
2807        self.preindexed_refresh_attempts.insert(
2808            lock_key,
2809            PreindexedRefreshAttempt {
2810                attempted_at: Utc::now(),
2811                succeeded,
2812            },
2813        );
2814    }
2815
2816    fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
2817        {
2818            let locks = preindexed_refresh_locks()
2819                .read()
2820                .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
2821            if let Some(existing) = locks.get(lock_key) {
2822                return Ok(Arc::clone(existing));
2823            }
2824        }
2825
2826        let mut locks = preindexed_refresh_locks()
2827            .write()
2828            .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
2829        Ok(Arc::clone(
2830            locks
2831                .entry(lock_key.to_owned())
2832                .or_insert_with(|| Arc::new(Mutex::new(()))),
2833        ))
2834    }
2835
2836    fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
2837        let source = self.source_clone(source_index);
2838        let index_path = preindexed_index_path(&self.app_root, &source);
2839        let lock_key = self.preindexed_refresh_lock_key(&source);
2840        let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
2841        let _guard = refresh_lock
2842            .lock()
2843            .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
2844
2845        if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
2846            return Ok("source already refreshed".to_owned());
2847        }
2848
2849        let result = self.update_preindexed(source_index);
2850        self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
2851        let detail = result?;
2852        if !self.use_system_winget_sources {
2853            self.save_store()?;
2854        }
2855        Ok(detail)
2856    }
2857
2858    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
2859        let path_text = path.to_string_lossy().into_owned();
2860        let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
2861            .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
2862        database
2863            .connect()
2864            .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
2865    }
2866
2867    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
2868        let source = &mut self.store.sources[source_index];
2869        let state_dir = source_state_dir(&self.app_root, source);
2870        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
2871
2872        let mut last_error = None;
2873        for candidate in PREINDEXED_CANDIDATES {
2874            let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
2875            match self.client.get(&url).send() {
2876                Ok(response) if response.status().is_success() => {
2877                    let header_version = response
2878                        .headers()
2879                        .get("x-ms-meta-sourceversion")
2880                        .and_then(|value| value.to_str().ok())
2881                        .map(str::to_owned);
2882                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
2883                    let payload = bytes.to_vec();
2884                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
2885                        .context("preindexed package did not contain Public/index.db")?;
2886
2887                    fs::write(preindexed_package_path(&self.app_root, source), &payload)
2888                        .context("failed to persist source package")?;
2889                    let index_path = preindexed_index_path(&self.app_root, source);
2890                    let temp_index_path = state_dir.join(format!(
2891                        "index.{}.tmp",
2892                        Utc::now().timestamp_nanos_opt().unwrap_or_default()
2893                    ));
2894                    fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
2895                    replace_file(&temp_index_path, &index_path).context("failed to persist source index")?;
2896                    source.last_update = Some(Utc::now());
2897                    source.source_version = header_version;
2898                    return Ok(format!("downloaded {}", candidate));
2899                }
2900                Ok(response) => {
2901                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
2902                }
2903                Err(error) => {
2904                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
2905                }
2906            }
2907        }
2908
2909        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
2910    }
2911
2912    fn update_rest(&mut self, source_index: usize) -> Result<String> {
2913        let info = self.fetch_rest_information(source_index, true)?;
2914        let source = &mut self.store.sources[source_index];
2915        if !info.source_identifier.is_empty() {
2916            source.identifier = info.source_identifier.clone();
2917        }
2918        source.last_update = Some(Utc::now());
2919        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
2920        Ok("refreshed information cache".to_owned())
2921    }
2922
2923    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
2924        let source = self.source_clone(source_index);
2925        let cache_path = rest_information_cache_path(&self.app_root, &source);
2926
2927        if cache_path.exists() {
2928            let cache = serde_json::from_slice::<RestInfoCache>(
2929                &fs::read(&cache_path).context("failed to read REST information cache")?,
2930            )
2931            .context("failed to parse REST information cache")?;
2932            if cache.expires_at > Utc::now() {
2933                return Ok(cache.value);
2934            }
2935        }
2936
2937        self.fetch_rest_information(source_index, true)
2938    }
2939
2940    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
2941        let source = self.source_clone(source_index);
2942        let url = format!("{}/information", source.arg.trim_end_matches('/'));
2943        let response = self
2944            .client
2945            .get(url)
2946            .send()
2947            .context("REST information request failed")?
2948            .error_for_status()
2949            .context("REST information request returned an error")?;
2950
2951        let max_age = cache_control_max_age(&response);
2952        let json = response
2953            .json::<JsonValue>()
2954            .context("failed to parse REST information response")?;
2955        let data = json
2956            .get("Data")
2957            .cloned()
2958            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
2959        let info = serde_json::from_value::<RestInformation>(data)
2960            .context("failed to deserialize REST information payload")?;
2961
2962        if persist {
2963            let cache = RestInfoCache {
2964                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
2965                value: info.clone(),
2966            };
2967            write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
2968        }
2969
2970        Ok(info)
2971    }
2972
2973    fn load_v2_version_data(
2974        &self,
2975        source: &SourceRecord,
2976        package_rowid: i64,
2977        package_hash: &str,
2978    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
2979        let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
2980            .context("failed to reopen preindexed index for V2 version data")?;
2981        let package_hash = package_hash.to_ascii_lowercase();
2982        let package_id = query_optional_value(
2983            &connection,
2984            "SELECT id FROM packages WHERE rowid = ?1",
2985            vec![SqlValue::Integer(package_rowid)],
2986            |row| row_string(row, 0),
2987        )?
2988        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
2989        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
2990        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
2991        let yaml = decompress_mszyml(&file.bytes)?;
2992        let document =
2993            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
2994        Ok((document.versions, file.path))
2995    }
2996
2997    fn get_cached_source_file(
2998        &self,
2999        bucket: &str,
3000        source: &SourceRecord,
3001        relative_path: &str,
3002        expected_hash: Option<&str>,
3003    ) -> Result<CachedBytes> {
3004        let normalized_relative = relative_path.replace('\\', "/");
3005        let cache_path =
3006            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3007
3008        if cache_path.exists() {
3009            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3010            if hash_matches(expected_hash, &cached) {
3011                return Ok(CachedBytes {
3012                    path: cache_path,
3013                    bytes: cached,
3014                });
3015            }
3016        }
3017
3018        let url = format!(
3019            "{}/{}",
3020            source.arg.trim_end_matches('/'),
3021            normalized_relative.trim_start_matches('/')
3022        );
3023        let response = self
3024            .client
3025            .get(url)
3026            .send()
3027            .context("failed to fetch source file")?
3028            .error_for_status()
3029            .context("source file request returned an error")?;
3030        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3031
3032        if let Some(hash) = expected_hash {
3033            verify_hash(hash, &bytes)?;
3034        }
3035
3036        if let Some(parent) = cache_path.parent() {
3037            fs::create_dir_all(parent).context("failed to create cache directory")?;
3038        }
3039        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3040
3041        Ok(CachedBytes {
3042            path: cache_path,
3043            bytes,
3044        })
3045    }
3046
3047    fn get_or_fetch_rest_manifest(
3048        &mut self,
3049        source: &SourceRecord,
3050        package_id: &str,
3051        version: &str,
3052        channel: &str,
3053    ) -> Result<(Vec<u8>, PathBuf)> {
3054        let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
3055        if cache_path.exists() {
3056            return Ok((
3057                fs::read(&cache_path).context("failed to read cached REST manifest")?,
3058                cache_path,
3059            ));
3060        }
3061
3062        let info = self.load_rest_information_by_name(&source.name)?;
3063        let contract = choose_contract(&info.server_supported_versions)
3064            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3065        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3066
3067        let mut params = vec![("Version", version.to_owned())];
3068        if !channel.is_empty() {
3069            params.push(("Channel", channel.to_owned()));
3070        }
3071        if info
3072            .required_query_parameters
3073            .iter()
3074            .any(|value| value.eq_ignore_ascii_case("Market"))
3075        {
3076            params.push(("Market", default_market()));
3077        }
3078
3079        let response = self
3080            .client
3081            .get(url)
3082            .header("Version", contract)
3083            .query(&params)
3084            .send()
3085            .context("REST manifest request failed")?
3086            .error_for_status()
3087            .context("REST manifest request returned an error")?;
3088        let bytes = response
3089            .bytes()
3090            .context("failed to read REST manifest response")?
3091            .to_vec();
3092
3093        if let Some(parent) = cache_path.parent() {
3094            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3095        }
3096        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3097
3098        Ok((bytes, cache_path))
3099    }
3100
3101    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3102        let index = self
3103            .store
3104            .sources
3105            .iter()
3106            .position(|source| source.name.eq_ignore_ascii_case(name))
3107            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3108        self.load_rest_information(index)
3109    }
3110
3111    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3112        if let Some(name) = source_name {
3113            let index = self
3114                .store
3115                .sources
3116                .iter()
3117                .position(|source| source.name.eq_ignore_ascii_case(name))
3118                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3119            return Ok(vec![index]);
3120        }
3121
3122        let mut indexes: Vec<_> = self
3123            .store
3124            .sources
3125            .iter()
3126            .enumerate()
3127            .filter(|(_, source)| !source.explicit)
3128            .collect();
3129        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3130            right_source
3131                .priority
3132                .cmp(&left_source.priority)
3133                .then_with(|| left_index.cmp(right_index))
3134        });
3135        Ok(indexes.into_iter().map(|(index, _)| index).collect())
3136    }
3137
3138    fn source_clone(&self, index: usize) -> SourceRecord {
3139        self.store.sources[index].clone()
3140    }
3141
3142    fn refresh_system_winget_sources(&mut self) -> Result<()> {
3143        if self.use_system_winget_sources {
3144            self.store = load_system_winget_source_store()?;
3145        }
3146        Ok(())
3147    }
3148
3149    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3150        self.refresh_system_winget_sources()?;
3151        let selected_names: Vec<_> = if source_name.is_none() {
3152            self.store.sources.iter().map(|source| source.name.clone()).collect()
3153        } else {
3154            self.resolve_source_indexes(source_name)?
3155                .into_iter()
3156                .map(|index| self.store.sources[index].name.clone())
3157                .collect()
3158        };
3159
3160        let detail = system_winget_update_sources(source_name)?;
3161        self.refresh_system_winget_sources()?;
3162
3163        Ok(selected_names
3164            .into_iter()
3165            .filter_map(|name| {
3166                self.store
3167                    .sources
3168                    .iter()
3169                    .find(|source| source.name.eq_ignore_ascii_case(&name))
3170                    .map(|source| SourceUpdateResult {
3171                        name: source.name.clone(),
3172                        kind: source.kind,
3173                        detail: detail.clone(),
3174                    })
3175            })
3176            .collect())
3177    }
3178}
3179
3180#[derive(Debug)]
3181struct CachedBytes {
3182    path: PathBuf,
3183    bytes: Vec<u8>,
3184}
3185
3186#[derive(Debug, Clone)]
3187struct V1SearchRow {
3188    manifest_rowid: i64,
3189    package_rowid: i64,
3190    version: String,
3191    channel: String,
3192    id: String,
3193    name: String,
3194    moniker: Option<String>,
3195    match_criteria: Option<String>,
3196}
3197
3198#[derive(Debug, Clone)]
3199struct V1VersionRow {
3200    version: String,
3201    channel: String,
3202    pathpart_id: i64,
3203    manifest_hash: Option<String>,
3204}
3205
3206#[derive(Debug, Clone)]
3207struct V2SearchRow {
3208    package_rowid: i64,
3209    package_hash: String,
3210    id: String,
3211    name: String,
3212    moniker: Option<String>,
3213    version: String,
3214    match_criteria: Option<String>,
3215}
3216
3217fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3218    query.query.is_some()
3219        || query.id.is_some()
3220        || query.name.is_some()
3221        || query.moniker.is_some()
3222        || query.tag.is_some()
3223        || query.command.is_some()
3224        || query.source.is_some()
3225}
3226
3227fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3228    PackageQuery {
3229        query: query.query.clone(),
3230        id: query.id.clone(),
3231        name: query.name.clone(),
3232        moniker: query.moniker.clone(),
3233        tag: query.tag.clone(),
3234        command: query.command.clone(),
3235        source: query.source.clone(),
3236        count: Some(LIST_LOOKUP_MAX_RESULTS),
3237        exact: query.exact,
3238        version: None,
3239        channel: None,
3240        locale: None,
3241        installer_type: None,
3242        installer_architecture: None,
3243        platform: None,
3244        os_version: None,
3245        install_scope: query.install_scope.clone(),
3246    }
3247}
3248
3249fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3250    query.query.is_some() || query.id.is_some() || query.name.is_some()
3251}
3252
3253fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3254    package
3255        .correlated
3256        .as_ref()
3257        .and_then(|candidate| candidate.version.as_deref())
3258        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3259}
3260
3261fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3262    package.installed_version.eq_ignore_ascii_case("Unknown")
3263}
3264
3265fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3266    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
3267    // Discord). Explicit `--id` filters surface them regardless.
3268    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3269        return false;
3270    }
3271    // Hide rows whose latest catalog version has no installer the host can
3272    // run — winget refuses to upgrade past an unusable architecture.
3273    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3274        return false;
3275    }
3276    installed_package_has_upgrade(package)
3277        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3278}
3279
3280/// Collapses installed entries that correlate to the same catalog package id
3281/// (case-insensitive within a source) down to a single representative — the
3282/// one with the highest installed version. Uncorrelated entries pass through
3283/// untouched; they can't appear in `upgrade` output anyway (the upgrade
3284/// filter requires a correlation), so they're effectively a no-op here.
3285fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3286    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3287    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3288
3289    for package in packages {
3290        match package.correlated.as_ref() {
3291            Some(candidate) => {
3292                let key = (
3293                    candidate.id.to_ascii_lowercase(),
3294                    candidate.source_name.to_ascii_lowercase(),
3295                );
3296                let keep_new = match by_id.get(&key) {
3297                    Some(existing) => {
3298                        // Prefer rows whose `installed_version` was remapped to
3299                        // a catalog Version (via aMiV/aMaV). Comparing a raw
3300                        // ARP DisplayVersion like `40.10.18029` against the
3301                        // catalog's `10.0.300` is meaningless and would also
3302                        // hide the row that legitimately upgrades to it.
3303                        match (
3304                            package.installed_version_canonical,
3305                            existing.installed_version_canonical,
3306                        ) {
3307                            (true, false) => true,
3308                            (false, true) => false,
3309                            _ => {
3310                                compare_version(&package.installed_version, &existing.installed_version)
3311                                    == Ordering::Greater
3312                            }
3313                        }
3314                    }
3315                    None => true,
3316                };
3317                if keep_new {
3318                    by_id.insert(key, package);
3319                }
3320            }
3321            None => uncorrelated.push(package),
3322        }
3323    }
3324
3325    let mut result = uncorrelated;
3326    result.extend(by_id.into_values());
3327    result
3328}
3329
3330fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3331    let mut source_specific = None;
3332    let mut source_agnostic = None;
3333
3334    for pin in pins {
3335        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3336            continue;
3337        }
3338
3339        if pin.source_id.is_empty() {
3340            if source_agnostic.is_none() {
3341                source_agnostic = Some(pin);
3342            }
3343        } else if item
3344            .source_name
3345            .as_deref()
3346            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3347        {
3348            source_specific = Some(pin);
3349            break;
3350        }
3351    }
3352
3353    source_specific.or(source_agnostic)
3354}
3355
3356fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3357    let Some(available_version) = item.available_version.as_deref() else {
3358        return false;
3359    };
3360    let Some(pin) = find_applicable_pin(item, pins) else {
3361        return false;
3362    };
3363
3364    match pin.pin_type {
3365        PinType::Blocking => true,
3366        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3367    }
3368}
3369
3370fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3371    if pattern.trim().is_empty() || pattern == "*" {
3372        return true;
3373    }
3374
3375    if !pattern.contains('*') {
3376        return version.eq_ignore_ascii_case(pattern);
3377    }
3378
3379    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3380        let prefix = &pattern[..pattern.len() - 1];
3381        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3382    }
3383
3384    version.eq_ignore_ascii_case(pattern)
3385}
3386
3387fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3388    let available_version = package.correlated.as_ref().and_then(|candidate| {
3389        candidate.version.as_ref().and_then(|candidate_version| {
3390            if installed_package_has_unknown_version(&package)
3391                || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3392            {
3393                Some(candidate_version.clone())
3394            } else {
3395                None
3396            }
3397        })
3398    });
3399    let source_name = package
3400        .correlated
3401        .as_ref()
3402        .map(|candidate| candidate.source_name.clone())
3403        .filter(|value| !value.is_empty());
3404    let id = package
3405        .correlated
3406        .as_ref()
3407        .map(|candidate| candidate.id.clone())
3408        .unwrap_or_else(|| package.local_id.clone());
3409
3410    ListMatch {
3411        name: package.name,
3412        id,
3413        local_id: package.local_id,
3414        installed_version: package.installed_version,
3415        available_version,
3416        source_name,
3417        publisher: package.publisher,
3418        scope: package.scope,
3419        installer_category: package.installer_category,
3420        install_location: package.install_location,
3421        package_family_names: package.package_family_names,
3422        product_codes: package.product_codes,
3423        upgrade_codes: package.upgrade_codes,
3424    }
3425}
3426
3427fn list_sort_weight(package: &InstalledPackage) -> usize {
3428    if package.local_id.starts_with("ARP\\") {
3429        0
3430    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3431        1
3432    } else {
3433        2
3434    }
3435}
3436
3437fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3438    let correlated = package.correlated.as_ref();
3439
3440    if let Some(value) = &query.id {
3441        let local_match = matches_text(&package.local_id, value, query.exact);
3442        let correlated_match = correlated
3443            .map(|candidate| matches_text(&candidate.id, value, query.exact))
3444            .unwrap_or(false);
3445        if !local_match && !correlated_match {
3446            return false;
3447        }
3448    }
3449
3450    if let Some(value) = &query.name
3451        && !matches_text(&package.name, value, query.exact)
3452    {
3453        return false;
3454    }
3455
3456    if let Some(value) = &query.product_code
3457        && !package
3458            .product_codes
3459            .iter()
3460            .any(|code| code.eq_ignore_ascii_case(value))
3461    {
3462        return false;
3463    }
3464
3465    if let Some(value) = &query.version
3466        && !package.installed_version.eq_ignore_ascii_case(value)
3467    {
3468        return false;
3469    }
3470
3471    if let Some(value) = &query.query {
3472        let local_match =
3473            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3474        let correlated_match = correlated
3475            .map(|candidate| {
3476                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3477            })
3478            .unwrap_or(false);
3479        if !local_match && !correlated_match {
3480            return false;
3481        }
3482    }
3483
3484    if let Some(source) = &query.source
3485        && correlated
3486            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3487            .unwrap_or(true)
3488    {
3489        return false;
3490    }
3491
3492    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3493        return false;
3494    }
3495
3496    true
3497}
3498
3499fn correlate_installed_package(
3500    package: &InstalledPackage,
3501    candidates: &[SearchMatch],
3502    allow_loose_name_match: bool,
3503) -> Option<SearchMatch> {
3504    // MSIX entries can only be correlated through their PackageFamilyName.
3505    // That lookup happens earlier in `correlate_installed_via_index`; if it
3506    // didn't find a match, name-based fallback is wrong — the catalog
3507    // doesn't carry this MSIX's identity, so any name collision (e.g. the
3508    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
3509    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
3510    // doesn't make.
3511    if package.local_id.starts_with("MSIX\\") {
3512        return None;
3513    }
3514
3515    let installed_name = normalize_correlation_name(&package.name);
3516    let installed_name_lower = package.name.to_ascii_lowercase();
3517    let candidate_names = correlation_name_candidates(&package.name);
3518
3519    let mut scored: Vec<(i32, SearchMatch)> = candidates
3520        .iter()
3521        .filter_map(|candidate| {
3522            let candidate_name = normalize_correlation_name(&candidate.name);
3523            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3524                1000
3525            } else if candidate_names.iter().any(|name| {
3526                let normalized = normalize_correlation_name(name);
3527                normalized == candidate_name
3528            }) {
3529                900
3530            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3531                700
3532            } else {
3533                0
3534            };
3535
3536            if base_score == 0 {
3537                return None;
3538            }
3539
3540            // Tiebreaker so we pick the *right* candidate when multiple catalog
3541            // entries collapse to the same normalized name or all loose-match a
3542            // long installed name. Two failure modes this addresses:
3543            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
3544            //     alphanumeric filter strips `+`/`-`), so both score 900 and
3545            //     `max_by_key` arbitrarily picked the latter.
3546            //   • Loose substring match picks up `Studio` inside
3547            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
3548            //     `Microsoft.DotNet.SDK.10` when both score 700.
3549            // The bonus rewards catalog names that appear verbatim in the
3550            // installed display name — strongly when they anchor the start,
3551            // weaker when they're embedded.
3552            let candidate_lower = candidate.name.to_ascii_lowercase();
3553            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3554                100
3555            } else if installed_name_lower.contains(&candidate_lower) {
3556                50
3557            } else {
3558                0
3559            };
3560
3561            Some((base_score + prefix_bonus, candidate.clone()))
3562        })
3563        .collect();
3564
3565    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3566    let mut iter = scored.into_iter();
3567    let (top_score, top_match) = iter.next()?;
3568
3569    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
3570    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
3571    // normalize their names to `git` and tie at the same score — the only
3572    // signal that disambiguates them, publisher, isn't part of this scoring
3573    // function. winget refuses the correlation in this case (the install
3574    // shows up with an empty Source); we do the same to avoid manufacturing
3575    // a fake upgrade against the wrong package.
3576    if let Some((next_score, _)) = iter.next()
3577        && next_score == top_score
3578    {
3579        return None;
3580    }
3581    Some(top_match)
3582}
3583
3584fn correlation_name_candidates(name: &str) -> Vec<String> {
3585    let mut candidates = Vec::new();
3586    candidates.push(name.trim().to_owned());
3587
3588    let mut trimmed = name.trim().to_owned();
3589    if let Some(index) = trimmed.find(" (") {
3590        trimmed.truncate(index);
3591    }
3592
3593    let mut words = Vec::new();
3594    for token in trimmed.split_whitespace() {
3595        let lower = token
3596            .trim_matches(|ch: char| ch == '(' || ch == ')')
3597            .to_ascii_lowercase();
3598        if !words.is_empty()
3599            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3600        {
3601            break;
3602        }
3603        words.push(token);
3604    }
3605
3606    if !words.is_empty() {
3607        candidates.push(words.join(" "));
3608    }
3609
3610    candidates.sort();
3611    candidates.dedup();
3612    candidates
3613}
3614
3615fn normalize_correlation_name(value: &str) -> String {
3616    value
3617        .chars()
3618        .filter(|ch| ch.is_ascii_alphanumeric())
3619        .flat_map(|ch| ch.to_lowercase())
3620        .collect()
3621}
3622
3623struct V2PackageMetadata {
3624    rowid: i64,
3625    id: String,
3626    name: String,
3627    moniker: Option<String>,
3628    latest_version: String,
3629    package_hash: String,
3630}
3631
3632/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
3633/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
3634/// and the field that produced the match. This is winget's primary correlation
3635/// path; falling back to display-name matching is only correct for ARP entries
3636/// that lack all three identifiers.
3637fn lookup_identity_match_v2(
3638    connection: &Connection,
3639    package: &InstalledPackage,
3640) -> Result<Option<(i64, &'static str)>> {
3641    for pfn in &package.package_family_names {
3642        if let Some(rowid) = query_optional_value(
3643            connection,
3644            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3645            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3646            |row| row_i64(row, 0),
3647        )? {
3648            return Ok(Some((rowid, "PackageFamilyName")));
3649        }
3650    }
3651    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
3652    // identity — it stays stable across versions, while ProductCode changes
3653    // every release. When a single ProductCode happens to also be listed
3654    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
3655    // installer rows from earlier LTS-line ProductCodes, while the v24
3656    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
3657    // the upgrade-side match represents the user's "package family" — the
3658    // one winget uses.
3659    for code in &package.upgrade_codes {
3660        if let Some(rowid) = query_optional_value(
3661            connection,
3662            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3663            vec![SqlValue::Text(code.to_ascii_lowercase())],
3664            |row| row_i64(row, 0),
3665        )? {
3666            return Ok(Some((rowid, "UpgradeCode")));
3667        }
3668    }
3669    for code in &package.product_codes {
3670        if let Some(rowid) = query_optional_value(
3671            connection,
3672            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3673            vec![SqlValue::Text(code.to_ascii_lowercase())],
3674            |row| row_i64(row, 0),
3675        )? {
3676            return Ok(Some((rowid, "ProductCode")));
3677        }
3678    }
3679    Ok(None)
3680}
3681
3682/// Resolves a v2 package by its catalog id back to its rowid + hash so the
3683/// post-correlation enrichment pass can load versionData for rows that
3684/// correlated through the name-based fallback (which throws the rowid away).
3685fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3686    query_optional_value(
3687        connection,
3688        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3689        vec![SqlValue::Text(package_id.to_owned())],
3690        |row| {
3691            Ok(V2PackageMetadata {
3692                rowid: row_i64(row, 0)?,
3693                id: row_string(row, 1)?,
3694                name: row_string(row, 2)?,
3695                moniker: row_opt_string(row, 3)?,
3696                latest_version: row_string(row, 4)?,
3697                package_hash: row_hex_string(row, 5)?,
3698            })
3699        },
3700    )
3701}
3702
3703/// MSIX entries whose Windows resource-string display name (e.g.
3704/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
3705/// would otherwise leak that placeholder into pinget's output. winget
3706/// resolves it via `Windows.Management.Deployment` and shows the catalog's
3707/// `PackageName`; we mirror that by substituting the correlated catalog
3708/// name once the package is correlated, which is the parity-faithful
3709/// substitute we can produce without taking a WinRT dependency.
3710fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3711    if !package.local_id.starts_with("MSIX\\") {
3712        return;
3713    }
3714    if !package.name.starts_with("ms-resource:") {
3715        return;
3716    }
3717    let Some(candidate) = package.correlated.as_ref() else {
3718        return;
3719    };
3720    if !candidate.name.is_empty() {
3721        package.name = candidate.name.clone();
3722    }
3723}
3724
3725fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3726    query_optional_value(
3727        connection,
3728        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3729        vec![SqlValue::Integer(package_rowid)],
3730        |row| {
3731            Ok(V2PackageMetadata {
3732                rowid: package_rowid,
3733                id: row_string(row, 0)?,
3734                name: row_string(row, 1)?,
3735                moniker: row_opt_string(row, 2)?,
3736                latest_version: row_string(row, 3)?,
3737                package_hash: row_hex_string(row, 4)?,
3738            })
3739        },
3740    )
3741}
3742
3743fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3744    query_optional_value(
3745        connection,
3746        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3747        Vec::new(),
3748        |row| row_i64(row, 0),
3749    )
3750    .ok()
3751    .flatten()
3752    .is_some()
3753}
3754
3755/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
3756/// pair matches the installed package — if and only if the match is
3757/// unique. Multiple matches mean the normalization wasn't strong enough to
3758/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
3759/// distinct catalog packages); winget refuses to correlate in that case
3760/// and we do the same.
3761fn lookup_unique_normalized_identity(
3762    connection: &Connection,
3763    norm_name: &str,
3764    norm_publisher: &str,
3765) -> Result<Option<i64>> {
3766    let rows: Vec<i64> = query_rows(
3767        connection,
3768        "SELECT DISTINCT n.package FROM norm_names2 n \
3769         WHERE n.norm_name = ?1 \
3770         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3771         LIMIT 2",
3772        vec![
3773            SqlValue::Text(norm_name.to_owned()),
3774            SqlValue::Text(norm_publisher.to_owned()),
3775        ],
3776        |row| row_i64(row, 0),
3777    )?;
3778    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3779}
3780
3781fn v2_identity_tables_present(connection: &Connection) -> bool {
3782    query_optional_value(
3783        connection,
3784        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3785        Vec::new(),
3786        |row| row_i64(row, 0),
3787    )
3788    .ok()
3789    .flatten()
3790    .is_some()
3791}
3792
3793/// Translates an ARP DisplayVersion to the catalog Version it maps to.
3794/// For packages like the .NET SDK or Python the ARP version is an MSI build
3795/// number (e.g. `10.1.826.23019`) unrelated to the public version
3796/// (`10.0.108`). winget records the ARP range per catalog version in
3797/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
3798/// contains the installed version.
3799fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3800    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3801        return None;
3802    }
3803    for entry in entries {
3804        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3805            continue;
3806        };
3807        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3808        {
3809            return Some(entry.version.clone());
3810        }
3811    }
3812    None
3813}
3814
3815/// Returns the latest catalog Version that carries ARP-range metadata
3816/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
3817/// publish "internal" version rows whose `v` is an MSI build number
3818/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
3819/// upgrade targets — when the installed side was matched through an ARP
3820/// range, the available side must be a peer that also exposes ARP bounds so
3821/// the two versions compare on the same scale. Returns `None` when no entry
3822/// has bounds; the caller should fall back to `packages.latest_version`.
3823fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3824    entries
3825        .iter()
3826        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3827        .max_by(|a, b| compare_version(&a.version, &b.version))
3828        .map(|entry| entry.version.clone())
3829}
3830
3831#[cfg(windows)]
3832fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3833    let mut packages = Vec::new();
3834    let mut seen = BTreeSet::new();
3835
3836    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3837    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3838
3839    // MSI registers UpgradeCode → ProductCode mappings under
3840    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
3841    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
3842    // entries don't expose `UpgradeCode` directly, so winget reads it from
3843    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
3844    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
3845    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
3846    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3847
3848    if machine {
3849        collect_uninstall_view(
3850            &mut packages,
3851            &mut seen,
3852            &upgrade_codes,
3853            RegKey::predef(HKEY_LOCAL_MACHINE),
3854            "Machine",
3855            "X64",
3856            KEY_READ | KEY_WOW64_64KEY,
3857        )?;
3858        collect_uninstall_view(
3859            &mut packages,
3860            &mut seen,
3861            &upgrade_codes,
3862            RegKey::predef(HKEY_LOCAL_MACHINE),
3863            "Machine",
3864            "X86",
3865            KEY_READ | KEY_WOW64_32KEY,
3866        )?;
3867        collect_appmodel_packages(
3868            &mut packages,
3869            &mut seen,
3870            RegKey::predef(HKEY_LOCAL_MACHINE),
3871            "Machine",
3872            KEY_READ | KEY_WOW64_64KEY,
3873        )?;
3874    }
3875
3876    if user {
3877        collect_uninstall_view(
3878            &mut packages,
3879            &mut seen,
3880            &upgrade_codes,
3881            RegKey::predef(HKEY_CURRENT_USER),
3882            "User",
3883            "X64",
3884            KEY_READ,
3885        )?;
3886        collect_appmodel_packages(
3887            &mut packages,
3888            &mut seen,
3889            RegKey::predef(HKEY_CURRENT_USER),
3890            "User",
3891            KEY_READ,
3892        )?;
3893    }
3894
3895    Ok(packages)
3896}
3897
3898/// Builds a `ProductCode → UpgradeCode` map (both in standard
3899/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
3900/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
3901/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
3902/// flipped ProductCodes registered under that UpgradeCode.
3903#[cfg(windows)]
3904fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
3905    let mut map = HashMap::new();
3906    if include_machine {
3907        collect_msi_upgrade_codes_from(
3908            RegKey::predef(HKEY_LOCAL_MACHINE),
3909            r"SOFTWARE\Classes\Installer\UpgradeCodes",
3910            KEY_READ | KEY_WOW64_64KEY,
3911            &mut map,
3912        );
3913    }
3914    if include_user {
3915        collect_msi_upgrade_codes_from(
3916            RegKey::predef(HKEY_CURRENT_USER),
3917            r"Software\Microsoft\Installer\UpgradeCodes",
3918            KEY_READ,
3919            &mut map,
3920        );
3921    }
3922    map
3923}
3924
3925#[cfg(windows)]
3926fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
3927    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
3928        return;
3929    };
3930    for key_name in upgrade_codes.enum_keys().flatten() {
3931        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
3932            continue;
3933        };
3934        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
3935            continue;
3936        };
3937        for (value_name, _) in subkey.enum_values().flatten() {
3938            if let Some(product_code) = unflip_packed_guid(&value_name) {
3939                // ARP-side keys are likely to win the latest insert; first one
3940                // is fine — we just need *any* mapping. Use `entry` so the
3941                // first writer per ProductCode wins to stay deterministic.
3942                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
3943            }
3944        }
3945    }
3946}
3947
3948/// Converts the 32-character "packed GUID" used inside the MSI Installer
3949/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
3950/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
3951/// then eight 2-char byte pairs) of the GUID's hex representation.
3952#[cfg(windows)]
3953fn unflip_packed_guid(packed: &str) -> Option<String> {
3954    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
3955        return None;
3956    }
3957    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
3958    let bytes = packed.as_bytes();
3959    let mut reversed = String::with_capacity(32);
3960    let mut offset = 0;
3961    for size in CHUNKS {
3962        let chunk = &bytes[offset..offset + size];
3963        for &c in chunk.iter().rev() {
3964            reversed.push(c as char);
3965        }
3966        offset += size;
3967    }
3968    let r = reversed.to_ascii_lowercase();
3969    Some(format!(
3970        "{{{}-{}-{}-{}-{}}}",
3971        &r[0..8],
3972        &r[8..12],
3973        &r[12..16],
3974        &r[16..20],
3975        &r[20..32]
3976    ))
3977}
3978
3979#[cfg(not(windows))]
3980fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3981    Ok(Vec::new())
3982}
3983
3984#[cfg(windows)]
3985fn collect_uninstall_view(
3986    packages: &mut Vec<InstalledPackage>,
3987    seen: &mut BTreeSet<String>,
3988    upgrade_code_map: &HashMap<String, String>,
3989    root: RegKey,
3990    scope: &str,
3991    arch: &str,
3992    flags: u32,
3993) -> Result<()> {
3994    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
3995
3996    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
3997        Ok(key) => key,
3998        Err(_) => return Ok(()),
3999    };
4000
4001    for key_name in uninstall.enum_keys().flatten() {
4002        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4003            Ok(key) => key,
4004            Err(_) => continue,
4005        };
4006        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4007        {
4008            continue;
4009        }
4010
4011        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4012            continue;
4013        };
4014
4015        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4016        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4017        let publisher = read_reg_string(&subkey, "Publisher");
4018        let install_location = read_reg_string(&subkey, "InstallLocation");
4019        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4020            .into_iter()
4021            .collect::<Vec<_>>();
4022        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4023        if product_codes.is_empty() && looks_like_product_code(&key_name) {
4024            product_codes.push(key_name.to_ascii_lowercase());
4025        }
4026        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4027        if upgrade_codes.is_empty() {
4028            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
4029            // Installer registry hive so identity correlation can fall back to
4030            // upgradecodes2 when productcodes2 picks the wrong sibling.
4031            for code in &product_codes {
4032                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4033                    upgrade_codes.push(uc.clone());
4034                    break;
4035                }
4036            }
4037        }
4038        let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4039            .as_deref()
4040            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4041        {
4042            // Honor the WinGet ARP signal so uninstall flows through
4043            // uninstall_portable (clean dir + registry removal) instead of
4044            // delegating the WinGet portable UninstallString to a winget
4045            // subprocess that only fully cleans up entries winget itself
4046            // installed.
4047            Some("portable".to_owned())
4048        } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4049            Some("msi".to_owned())
4050        } else if key_name.starts_with("MSIX\\") {
4051            Some("msix".to_owned())
4052        } else {
4053            Some("exe".to_owned())
4054        };
4055
4056        let dedupe_key = format!(
4057            "{}|{}|{}|{}",
4058            local_id,
4059            name.to_ascii_lowercase(),
4060            installed_version.to_ascii_lowercase(),
4061            publisher.clone().unwrap_or_default().to_ascii_lowercase()
4062        );
4063        if !seen.insert(dedupe_key) {
4064            continue;
4065        }
4066
4067        packages.push(InstalledPackage {
4068            name,
4069            local_id,
4070            installed_version,
4071            publisher,
4072            scope: Some(scope.to_owned()),
4073            installer_category,
4074            install_location,
4075            package_family_names,
4076            product_codes,
4077            upgrade_codes,
4078            correlated: None,
4079            installed_version_canonical: false,
4080            correlated_requires_explicit_upgrade: false,
4081            correlated_lacks_compatible_installer: false,
4082        });
4083    }
4084
4085    Ok(())
4086}
4087
4088#[cfg(windows)]
4089fn collect_appmodel_packages(
4090    packages: &mut Vec<InstalledPackage>,
4091    seen: &mut BTreeSet<String>,
4092    root: RegKey,
4093    scope: &str,
4094    flags: u32,
4095) -> Result<()> {
4096    const APPMODEL_PACKAGES_PATH: &str =
4097        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4098
4099    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4100        Ok(key) => key,
4101        Err(_) => return Ok(()),
4102    };
4103
4104    for key_name in appmodel.enum_keys().flatten() {
4105        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4106            Ok(key) => key,
4107            Err(_) => continue,
4108        };
4109
4110        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4111            continue;
4112        };
4113        let install_location = read_reg_string(&subkey, "PackageRootFolder");
4114        if install_location.as_deref().is_some_and(is_windows_system_path) {
4115            continue;
4116        }
4117
4118        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4119            continue;
4120        };
4121
4122        let local_id = format!(r"MSIX\{key_name}");
4123        let dedupe_key = format!(
4124            "{}|{}|{}",
4125            local_id,
4126            name.to_ascii_lowercase(),
4127            metadata.version.to_ascii_lowercase()
4128        );
4129        if !seen.insert(dedupe_key) {
4130            continue;
4131        }
4132
4133        packages.push(InstalledPackage {
4134            name,
4135            local_id,
4136            installed_version: metadata.version,
4137            publisher: None,
4138            scope: Some(scope.to_owned()),
4139            installer_category: Some("msix".to_owned()),
4140            install_location,
4141            package_family_names: vec![metadata.family_name],
4142            product_codes: Vec::new(),
4143            upgrade_codes: Vec::new(),
4144            correlated: None,
4145            installed_version_canonical: false,
4146            correlated_requires_explicit_upgrade: false,
4147            correlated_lacks_compatible_installer: false,
4148        });
4149    }
4150
4151    Ok(())
4152}
4153
4154#[cfg(windows)]
4155struct ParsedMsixPackageFullName {
4156    version: String,
4157    family_name: String,
4158}
4159
4160#[cfg(windows)]
4161fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4162    let segments = value.split('_').collect::<Vec<_>>();
4163    if segments.len() < 5 {
4164        return None;
4165    }
4166
4167    let name = segments[..segments.len() - 4].join("_");
4168    if name.is_empty() {
4169        return None;
4170    }
4171
4172    let version = segments[segments.len() - 4].trim();
4173    let resource_id = segments[segments.len() - 2].trim();
4174    let publisher_id = segments[segments.len() - 1].trim();
4175    if version.is_empty() || publisher_id.is_empty() {
4176        return None;
4177    }
4178
4179    let family_name = if resource_id.is_empty() {
4180        format!("{name}_{publisher_id}")
4181    } else {
4182        format!("{name}_{resource_id}_{publisher_id}")
4183    };
4184
4185    Some(ParsedMsixPackageFullName {
4186        version: version.to_owned(),
4187        family_name,
4188    })
4189}
4190
4191#[cfg(windows)]
4192fn is_windows_system_path(path: &str) -> bool {
4193    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
4194}
4195
4196#[cfg(windows)]
4197fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
4198    key.get_value::<String, _>(value_name)
4199        .ok()
4200        .map(|value| value.trim().to_owned())
4201        .filter(|value| !value.is_empty())
4202}
4203
4204#[cfg(windows)]
4205fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
4206    key.get_value::<u32, _>(value_name).ok()
4207}
4208
4209#[cfg(windows)]
4210fn looks_like_product_code(value: &str) -> bool {
4211    value.starts_with('{') && value.ends_with('}')
4212}
4213
4214fn ensure_app_dirs(app_root: &Path) -> Result<()> {
4215    fs::create_dir_all(app_root).context("failed to create app root directory")?;
4216    if uses_packaged_layout(app_root) {
4217        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
4218        let user_sources_path = packaged_user_sources_path(app_root)?;
4219        if let Some(parent) = user_sources_path.parent() {
4220            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
4221        }
4222    } else {
4223        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
4224    }
4225    Ok(())
4226}
4227
4228fn default_app_root() -> Result<PathBuf> {
4229    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
4230        return Ok(PathBuf::from(app_root));
4231    }
4232
4233    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
4234
4235    #[cfg(windows)]
4236    {
4237        if has_current_package_identity() {
4238            return Ok(local_app_data
4239                .join("Packages")
4240                .join(PACKAGED_FAMILY_NAME)
4241                .join("LocalState"));
4242        }
4243        Ok(local_app_data.join("Devolutions").join("Pinget"))
4244    }
4245
4246    #[cfg(not(windows))]
4247    {
4248        Ok(local_app_data.join("pinget"))
4249    }
4250}
4251
4252#[cfg(windows)]
4253fn has_current_package_identity() -> bool {
4254    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
4255    // process has no AppX package identity.
4256    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
4257
4258    let mut length: u32 = 0;
4259    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
4260    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
4261    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
4262    rc != APPMODEL_ERROR_NO_PACKAGE
4263}
4264
4265fn store_path(app_root: &Path) -> PathBuf {
4266    app_root.join("sources.json")
4267}
4268
4269fn user_settings_path(app_root: &Path) -> PathBuf {
4270    if uses_packaged_layout(app_root) {
4271        app_root.join("settings.json")
4272    } else {
4273        app_root.join("user-settings.json")
4274    }
4275}
4276
4277fn admin_settings_path(app_root: &Path) -> PathBuf {
4278    app_root.join("admin-settings.json")
4279}
4280
4281fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
4282    if uses_packaged_layout(app_root) {
4283        return app_root
4284            .join(packaged_source_type(source.kind))
4285            .join(sanitize_path_segment(&source.identifier));
4286    }
4287
4288    app_root.join("sources").join(sanitize_path_segment(&source.name))
4289}
4290
4291fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4292    source_state_dir(app_root, source).join("source.msix")
4293}
4294
4295fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4296    source_state_dir(app_root, source).join("index.db")
4297}
4298
4299#[cfg(windows)]
4300fn replace_file(source: &Path, target: &Path) -> Result<()> {
4301    let source_wide = source
4302        .as_os_str()
4303        .encode_wide()
4304        .chain(std::iter::once(0))
4305        .collect::<Vec<_>>();
4306    let target_wide = target
4307        .as_os_str()
4308        .encode_wide()
4309        .chain(std::iter::once(0))
4310        .collect::<Vec<_>>();
4311    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
4312    // SAFETY: Both paths are converted to null-terminated UTF-16 buffers that
4313    // live for the duration of the call; MoveFileExW does not retain them.
4314    let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
4315    if ok == 0 {
4316        return Err(std::io::Error::last_os_error())
4317            .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()));
4318    }
4319    Ok(())
4320}
4321
4322#[cfg(not(windows))]
4323fn replace_file(source: &Path, target: &Path) -> Result<()> {
4324    fs::rename(source, target)
4325        .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
4326}
4327
4328fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4329    source_state_dir(app_root, source).join("rest-information.json")
4330}
4331
4332fn rest_manifest_cache_path_with_root(
4333    app_root: &Path,
4334    source: &SourceRecord,
4335    package_id: &str,
4336    version: &str,
4337    channel: &str,
4338) -> PathBuf {
4339    let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
4340    let digest = sha256_hex(key.as_bytes());
4341    cache_root(app_root)
4342        .join("REST_M")
4343        .join(sanitize_path_segment(&source.identifier))
4344        .join(format!("{digest}.json"))
4345}
4346
4347fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
4348    cache_root(app_root)
4349        .join(bucket)
4350        .join(sanitize_path_segment(identifier))
4351}
4352
4353fn load_store(app_root: &Path) -> Result<SourceStore> {
4354    if uses_packaged_layout(app_root)
4355        && let Some(store) = load_packaged_store(app_root)?
4356    {
4357        return Ok(store);
4358    }
4359
4360    let path = store_path(app_root);
4361    if !path.exists() {
4362        let store = SourceStore::default();
4363        save_store(app_root, &store)?;
4364        return Ok(store);
4365    }
4366
4367    let bytes = fs::read(path).context("failed to read source store")?;
4368    serde_json::from_slice(&bytes).context("failed to parse source store")
4369}
4370
4371fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4372    if uses_system_winget_source_commands(app_root) {
4373        bail!("System WinGet source settings must be modified through winget.");
4374    }
4375
4376    if uses_packaged_layout(app_root) {
4377        return save_packaged_store(app_root, store);
4378    }
4379
4380    write_json(store_path(app_root), store)
4381}
4382
4383fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
4384    let user_sources_path = packaged_user_sources_path(app_root)?;
4385    let metadata_path = app_root.join("sources_metadata");
4386    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
4387    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
4388    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
4389}
4390
4391fn load_packaged_store_from_streams(
4392    user_sources_yaml: Option<&str>,
4393    metadata_yaml: Option<&str>,
4394) -> Result<Option<SourceStore>> {
4395    if is_secure_settings_stub(user_sources_yaml) {
4396        return load_system_winget_source_store().map(Some);
4397    }
4398
4399    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
4400}
4401
4402fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4403    let user_sources_path = packaged_user_sources_path(app_root)?;
4404    if let Some(parent) = user_sources_path.parent() {
4405        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
4406    }
4407
4408    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
4409        .context("failed to write packaged user_sources stream")?;
4410    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4411        .context("failed to write packaged source metadata")?;
4412    Ok(())
4413}
4414
4415fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4416    if !uses_packaged_layout(app_root) {
4417        return false;
4418    }
4419
4420    #[cfg(windows)]
4421    {
4422        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4423            return false;
4424        };
4425
4426        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4427        is_secure_settings_stub(user_sources_yaml.as_deref())
4428    }
4429
4430    #[cfg(not(windows))]
4431    {
4432        false
4433    }
4434}
4435
4436fn is_secure_settings_stub(value: Option<&str>) -> bool {
4437    value
4438        .map(str::trim_start)
4439        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4440}
4441
4442fn load_system_winget_source_store() -> Result<SourceStore> {
4443    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4444    Ok(SourceStore {
4445        sources: parse_system_winget_source_export(&result.stdout)?,
4446    })
4447}
4448
4449fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4450    run_checked_winget_source_command(
4451        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4452        &format!("add WinGet source '{name}'"),
4453    )?;
4454    Ok(())
4455}
4456
4457fn system_winget_remove_source(name: &str) -> Result<()> {
4458    run_checked_winget_source_command(
4459        vec![
4460            "source".to_owned(),
4461            "remove".to_owned(),
4462            "--name".to_owned(),
4463            name.to_owned(),
4464            "--disable-interactivity".to_owned(),
4465        ],
4466        &format!("remove WinGet source '{name}'"),
4467    )?;
4468    Ok(())
4469}
4470
4471fn system_winget_reset_source(name: &str) -> Result<()> {
4472    run_checked_winget_source_command(
4473        vec![
4474            "source".to_owned(),
4475            "reset".to_owned(),
4476            "--name".to_owned(),
4477            name.to_owned(),
4478            "--force".to_owned(),
4479            "--disable-interactivity".to_owned(),
4480        ],
4481        &format!("reset WinGet source '{name}'"),
4482    )?;
4483    Ok(())
4484}
4485
4486fn system_winget_reset_sources() -> Result<()> {
4487    run_checked_winget_source_command(
4488        vec![
4489            "source".to_owned(),
4490            "reset".to_owned(),
4491            "--force".to_owned(),
4492            "--disable-interactivity".to_owned(),
4493        ],
4494        "reset WinGet sources",
4495    )?;
4496    Ok(())
4497}
4498
4499fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4500    let mut args = vec!["source".to_owned(), "update".to_owned()];
4501    if let Some(name) = name {
4502        args.push("--name".to_owned());
4503        args.push(name.to_owned());
4504    }
4505
4506    args.push("--disable-interactivity".to_owned());
4507    let result = run_checked_winget_source_command(
4508        args,
4509        if name.is_some() {
4510            "update WinGet source"
4511        } else {
4512            "update WinGet sources"
4513        },
4514    )?;
4515    let detail = result.stdout.trim();
4516    Ok(if detail.is_empty() {
4517        "Done".to_owned()
4518    } else {
4519        detail.to_owned()
4520    })
4521}
4522
4523fn build_winget_source_export_arguments() -> Vec<String> {
4524    vec![
4525        "source".to_owned(),
4526        "export".to_owned(),
4527        "--disable-interactivity".to_owned(),
4528    ]
4529}
4530
4531fn build_winget_source_add_arguments(
4532    name: &str,
4533    arg: &str,
4534    kind: SourceKind,
4535    trust_level: &str,
4536    explicit: bool,
4537) -> Vec<String> {
4538    let mut args = vec![
4539        "source".to_owned(),
4540        "add".to_owned(),
4541        "--name".to_owned(),
4542        name.to_owned(),
4543        "--arg".to_owned(),
4544        arg.to_owned(),
4545        "--type".to_owned(),
4546        packaged_source_type(kind).to_owned(),
4547        "--disable-interactivity".to_owned(),
4548    ];
4549
4550    if !trust_level.trim().is_empty() {
4551        args.push("--trust-level".to_owned());
4552        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4553            "trusted".to_owned()
4554        } else {
4555            "none".to_owned()
4556        });
4557    }
4558
4559    if explicit {
4560        args.push("--explicit".to_owned());
4561    }
4562
4563    args
4564}
4565
4566#[derive(Debug)]
4567struct WingetSourceCommandResult {
4568    exit_code: i32,
4569    stdout: String,
4570    stderr: String,
4571}
4572
4573type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4574
4575static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4576    RwLock::new(run_winget_source_command_process);
4577
4578fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4579    let result = run_winget_source_command(&args)?;
4580    if result.exit_code == 0 {
4581        return Ok(result);
4582    }
4583
4584    let detail = [result.stderr.trim(), result.stdout.trim()]
4585        .into_iter()
4586        .filter(|value| !value.is_empty())
4587        .collect::<Vec<_>>()
4588        .join("\n");
4589    if detail.is_empty() {
4590        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4591    }
4592
4593    bail!(
4594        "Failed to {action}; winget exited with code {}:\n{}",
4595        result.exit_code,
4596        detail
4597    );
4598}
4599
4600fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4601    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4602        .read()
4603        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4604    runner(args)
4605}
4606
4607fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4608    use std::process::Command;
4609
4610    let output = Command::new("winget")
4611        .args(args)
4612        .output()
4613        .context("failed to run winget source command")?;
4614
4615    Ok(WingetSourceCommandResult {
4616        exit_code: output.status.code().unwrap_or(-1),
4617        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4618        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4619    })
4620}
4621
4622fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4623    let trimmed = output.trim();
4624    if trimmed.is_empty() {
4625        return Ok(Vec::new());
4626    }
4627
4628    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4629        && let Some(sources) = parse_system_winget_source_export_value(&value)?
4630    {
4631        return Ok(order_sources(sources));
4632    }
4633
4634    let mut sources = Vec::new();
4635    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4636        if line.is_empty() {
4637            continue;
4638        }
4639
4640        let value: JsonValue = serde_json::from_str(line)
4641            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4642        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4643            bail!("WinGet source export returned a non-source line: {line}");
4644        };
4645        sources.append(&mut parsed);
4646    }
4647
4648    Ok(order_sources(sources))
4649}
4650
4651fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4652    match value {
4653        JsonValue::Array(items) => items
4654            .iter()
4655            .map(parse_system_winget_source_record)
4656            .collect::<Result<Vec<_>>>()
4657            .map(Some),
4658        JsonValue::Object(object) => {
4659            if let Some(JsonValue::Array(items)) = object.get("Sources") {
4660                items
4661                    .iter()
4662                    .map(parse_system_winget_source_record)
4663                    .collect::<Result<Vec<_>>>()
4664                    .map(Some)
4665            } else if object.contains_key("Name") {
4666                Ok(Some(vec![parse_system_winget_source_record(value)?]))
4667            } else {
4668                Ok(None)
4669            }
4670        }
4671        _ => Ok(None),
4672    }
4673}
4674
4675fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4676    let object = value
4677        .as_object()
4678        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4679    let name = required_json_string(object, "Name")?;
4680    let source_type = required_json_string(object, "Type")?;
4681    let arg = required_json_string(object, "Arg")?;
4682    let kind = parse_system_winget_source_kind(&source_type)?;
4683    let identifier = optional_json_string(object, "Identifier")
4684        .or_else(|| optional_json_string(object, "Data"))
4685        .unwrap_or_else(|| name.clone());
4686
4687    Ok(SourceRecord {
4688        name,
4689        kind,
4690        arg,
4691        identifier,
4692        trust_level: parse_system_winget_trust_level(object),
4693        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4694        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4695        last_update: None,
4696        source_version: None,
4697    })
4698}
4699
4700fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4701    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4702}
4703
4704fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4705    match object.get(key)? {
4706        JsonValue::String(value) => Some(value.clone()),
4707        JsonValue::Number(value) => Some(value.to_string()),
4708        _ => None,
4709    }
4710}
4711
4712fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4713    match object.get(key)? {
4714        JsonValue::Bool(value) => Some(*value),
4715        JsonValue::String(value) => value.parse().ok(),
4716        _ => None,
4717    }
4718}
4719
4720fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4721    match object.get(key)? {
4722        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4723        JsonValue::String(value) => value.parse().ok(),
4724        _ => None,
4725    }
4726}
4727
4728fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4729    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4730        Ok(SourceKind::PreIndexed)
4731    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4732        Ok(SourceKind::Rest)
4733    } else {
4734        bail!("Unsupported WinGet source type '{value}'.")
4735    }
4736}
4737
4738fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4739    let Some(value) = object.get("TrustLevel") else {
4740        return "None".to_owned();
4741    };
4742
4743    match value {
4744        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4745        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4746        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4747        _ => "None".to_owned(),
4748    }
4749}
4750
4751fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4752    value
4753        .as_str()
4754        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4755}
4756
4757fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4758    sources.sort_by(|left, right| {
4759        left.explicit
4760            .cmp(&right.explicit)
4761            .then_with(|| right.priority.cmp(&left.priority))
4762            .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4763    });
4764    sources
4765}
4766
4767fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4768    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4769        return None;
4770    }
4771
4772    let mut sources = SourceStore::default().sources;
4773    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4774        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4775            continue;
4776        };
4777
4778        if yaml_bool(entry.get("IsTombstone")) {
4779            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4780            continue;
4781        }
4782
4783        let Some(mapped) = map_packaged_source_entry(&entry) else {
4784            continue;
4785        };
4786
4787        if let Some(existing) = sources
4788            .iter_mut()
4789            .find(|source| source.name.eq_ignore_ascii_case(&name))
4790        {
4791            *existing = mapped;
4792        } else {
4793            sources.push(mapped);
4794        }
4795    }
4796
4797    for entry in parse_packaged_yaml_entries(metadata_yaml) {
4798        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4799            continue;
4800        };
4801
4802        let Some(source) = sources
4803            .iter_mut()
4804            .find(|source| source.name.eq_ignore_ascii_case(&name))
4805        else {
4806            continue;
4807        };
4808
4809        if let Some(last_update) = entry
4810            .get("LastUpdate")
4811            .and_then(|value| value.as_deref())
4812            .and_then(parse_packaged_datetime)
4813        {
4814            source.last_update = Some(last_update);
4815        }
4816
4817        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4818            && !source_version.is_empty()
4819        {
4820            source.source_version = Some(source_version);
4821        }
4822    }
4823
4824    Some(SourceStore { sources })
4825}
4826
4827fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
4828    let Some(yaml) = yaml else {
4829        return Vec::new();
4830    };
4831
4832    let mut entries = Vec::new();
4833    let mut current: Option<BTreeMap<String, Option<String>>> = None;
4834    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
4835        let line = raw_line.trim_end();
4836        let trimmed = line.trim();
4837        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
4838            continue;
4839        }
4840
4841        if let Some(remainder) = trimmed.strip_prefix("- ") {
4842            if let Some(entry) = current.take() {
4843                entries.push(entry);
4844            }
4845
4846            let mut entry = BTreeMap::new();
4847            add_packaged_yaml_key_value(&mut entry, remainder);
4848            current = Some(entry);
4849            continue;
4850        }
4851
4852        if let Some(entry) = current.as_mut() {
4853            add_packaged_yaml_key_value(entry, trimmed);
4854        }
4855    }
4856
4857    if let Some(entry) = current {
4858        entries.push(entry);
4859    }
4860
4861    entries
4862}
4863
4864fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
4865    let Some((key, value)) = line.split_once(':') else {
4866        return;
4867    };
4868
4869    entry.insert(
4870        key.trim().to_owned(),
4871        Some(unquote_yaml_scalar(value.trim()).to_owned()),
4872    );
4873}
4874
4875fn unquote_yaml_scalar(value: &str) -> &str {
4876    if value.len() >= 2 {
4877        let bytes = value.as_bytes();
4878        let first = bytes[0] as char;
4879        let last = bytes[value.len() - 1] as char;
4880        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
4881            return &value[1..value.len() - 1];
4882        }
4883    }
4884
4885    value
4886}
4887
4888fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
4889    let name = entry.get("Name").and_then(|value| value.clone())?;
4890    let source_type = entry.get("Type").and_then(|value| value.clone())?;
4891    let arg = entry.get("Arg").and_then(|value| value.clone())?;
4892    let kind = parse_packaged_source_kind(&source_type)?;
4893
4894    Some(SourceRecord {
4895        name: name.clone(),
4896        kind,
4897        arg,
4898        identifier: entry
4899            .get("Identifier")
4900            .and_then(|value| value.clone())
4901            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
4902            .unwrap_or(name),
4903        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
4904        explicit: yaml_bool(entry.get("Explicit")),
4905        priority: entry
4906            .get("Priority")
4907            .and_then(|value| value.as_deref())
4908            .and_then(|value| value.parse::<i32>().ok())
4909            .unwrap_or_default(),
4910        last_update: None,
4911        source_version: None,
4912    })
4913}
4914
4915fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
4916    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4917        Some(SourceKind::PreIndexed)
4918    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4919        Some(SourceKind::Rest)
4920    } else {
4921        None
4922    }
4923}
4924
4925fn parse_packaged_trust_level(value: Option<&str>) -> String {
4926    match value {
4927        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
4928        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
4929        _ => "None".to_owned(),
4930    }
4931}
4932
4933fn yaml_bool(value: Option<&Option<String>>) -> bool {
4934    value
4935        .and_then(|raw| raw.as_deref())
4936        .and_then(|raw| raw.parse::<bool>().ok())
4937        .unwrap_or(false)
4938}
4939
4940fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
4941    if let Ok(unix_seconds) = value.parse::<i64>() {
4942        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
4943    }
4944
4945    DateTime::parse_from_rfc3339(value)
4946        .ok()
4947        .map(|value| value.with_timezone(&Utc))
4948}
4949
4950fn render_packaged_sources_yaml(store: &SourceStore) -> String {
4951    let mut lines = vec!["Sources:".to_owned()];
4952    for source in &store.sources {
4953        lines.push(format!("  - Name: {}", source.name));
4954        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
4955        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
4956        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
4957        lines.push(format!(
4958            "    TrustLevel: {}",
4959            if source.trust_level.eq_ignore_ascii_case("Trusted") {
4960                1
4961            } else {
4962                0
4963            }
4964        ));
4965        lines.push(format!("    Explicit: {}", source.explicit));
4966        lines.push(format!("    Priority: {}", source.priority));
4967        lines.push("    IsTombstone: false".to_owned());
4968    }
4969    lines.push(String::new());
4970    lines.join("\n")
4971}
4972
4973fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
4974    let mut lines = vec!["Sources:".to_owned()];
4975    for source in &store.sources {
4976        if source.last_update.is_none() && source.source_version.is_none() {
4977            continue;
4978        }
4979
4980        lines.push(format!("  - Name: {}", source.name));
4981        if let Some(last_update) = source.last_update {
4982            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
4983        }
4984        if let Some(source_version) = &source.source_version {
4985            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
4986        }
4987    }
4988    lines.push(String::new());
4989    lines.join("\n")
4990}
4991
4992fn yaml_scalar(value: &str) -> String {
4993    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
4994        format!("\"{}\"", value.replace('"', "\\\""))
4995    } else {
4996        value.to_owned()
4997    }
4998}
4999
5000fn uses_packaged_layout(app_root: &Path) -> bool {
5001    #[cfg(windows)]
5002    {
5003        try_get_packaged_local_state_root(app_root).is_some()
5004    }
5005
5006    #[cfg(not(windows))]
5007    {
5008        let _ = app_root;
5009        false
5010    }
5011}
5012
5013#[cfg(windows)]
5014fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
5015    let local_state = app_root.file_name()?.to_string_lossy();
5016    if !local_state.eq_ignore_ascii_case("LocalState") {
5017        return None;
5018    }
5019
5020    let family = app_root.parent()?.file_name()?.to_string_lossy();
5021    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
5022        return None;
5023    }
5024
5025    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
5026    if !packages.eq_ignore_ascii_case("Packages") {
5027        return None;
5028    }
5029
5030    Some(app_root.to_path_buf())
5031}
5032
5033#[cfg(windows)]
5034fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
5035    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
5036    Ok(packaged_secure_settings_root()?.join("user_sources"))
5037}
5038
5039#[cfg(not(windows))]
5040fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
5041    bail!("packaged WinGet source streams are only available on Windows")
5042}
5043
5044#[cfg(windows)]
5045fn packaged_secure_settings_root() -> Result<PathBuf> {
5046    let program_data = std::env::var_os("PROGRAMDATA")
5047        .map(PathBuf::from)
5048        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
5049    Ok(program_data
5050        .join("Microsoft")
5051        .join("WinGet")
5052        .join(current_user_sid()?)
5053        .join("settings")
5054        .join("pkg")
5055        .join(PACKAGED_NAME))
5056}
5057
5058#[cfg(windows)]
5059#[allow(clippy::multiple_unsafe_ops_per_block)]
5060fn current_user_sid() -> Result<String> {
5061    fn close_token(token: HANDLE) {
5062        if !token.is_null() {
5063            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
5064            unsafe {
5065                CloseHandle(token);
5066            }
5067        }
5068    }
5069
5070    let mut token: HANDLE = std::ptr::null_mut();
5071    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
5072    let current_process = unsafe { GetCurrentProcess() };
5073    // SAFETY: token points to valid storage for the process token handle on success.
5074    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
5075        bail!("failed to open current process token")
5076    }
5077
5078    let mut length = 0;
5079    // SAFETY: The null buffer call asks Windows for the required buffer length.
5080    unsafe {
5081        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
5082    }
5083
5084    let element_size = size_of::<TOKEN_USER>();
5085    let element_count = (length as usize).div_ceil(element_size);
5086    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
5087    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
5088    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
5089        close_token(token);
5090        bail!("failed to get current user token information")
5091    }
5092
5093    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
5094    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
5095    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
5096    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
5097    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
5098        close_token(token);
5099        bail!("failed to convert current user SID")
5100    }
5101
5102    let mut len = 0usize;
5103    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
5104    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
5105        len += 1;
5106    }
5107
5108    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
5109    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
5110    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
5111    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
5112    unsafe {
5113        LocalFree(sid_ptr.cast());
5114    }
5115    close_token(token);
5116    Ok(sid)
5117}
5118
5119fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
5120    app_root.join("Microsoft").join("Windows Package Manager")
5121}
5122
5123fn cache_root(app_root: &Path) -> PathBuf {
5124    if uses_packaged_layout(app_root) {
5125        packaged_file_cache_root(app_root)
5126    } else {
5127        default_cache_root_fallback()
5128    }
5129}
5130
5131fn default_cache_root_fallback() -> PathBuf {
5132    std::env::temp_dir().join("cache")
5133}
5134
5135fn packaged_source_type(kind: SourceKind) -> &'static str {
5136    match kind {
5137        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
5138        SourceKind::Rest => "Microsoft.Rest",
5139    }
5140}
5141
5142fn sanitize_path_segment(value: &str) -> String {
5143    value
5144        .chars()
5145        .map(|ch| match ch {
5146            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
5147            _ => ch,
5148        })
5149        .collect()
5150}
5151
5152fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
5153    if !path.exists() {
5154        return Ok(JsonMap::new());
5155    }
5156
5157    let bytes = fs::read(path).context("failed to read JSON file")?;
5158    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
5159    Ok(match value {
5160        JsonValue::Object(object) => object,
5161        _ => JsonMap::new(),
5162    })
5163}
5164
5165fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
5166    write_json(path, value)
5167}
5168
5169fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
5170    if let Some(parent) = path.parent() {
5171        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
5172    }
5173
5174    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
5175    fs::write(path, bytes).context("failed to write JSON file")
5176}
5177
5178fn merge_json_objects(
5179    current: &JsonMap<String, JsonValue>,
5180    update: &JsonMap<String, JsonValue>,
5181) -> JsonMap<String, JsonValue> {
5182    let mut merged = current.clone();
5183    for (key, value) in update {
5184        match (merged.get(key), value) {
5185            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
5186                merged.insert(
5187                    key.clone(),
5188                    JsonValue::Object(merge_json_objects(current_object, update_object)),
5189                );
5190            }
5191            _ => {
5192                merged.insert(key.clone(), value.clone());
5193            }
5194        }
5195    }
5196
5197    merged
5198}
5199
5200fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
5201    match expected {
5202        JsonValue::Object(expected_object) => match current {
5203            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
5204                current_object
5205                    .get(key)
5206                    .is_some_and(|current_child| json_contains(current_child, expected_child))
5207            }),
5208            _ => false,
5209        },
5210        JsonValue::Array(expected_array) => match current {
5211            JsonValue::Array(current_array) => {
5212                current_array.len() == expected_array.len()
5213                    && current_array
5214                        .iter()
5215                        .zip(expected_array.iter())
5216                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
5217            }
5218            _ => false,
5219        },
5220        _ => current == expected,
5221    }
5222}
5223
5224fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
5225    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
5226        "none" | "default" => Ok("None".to_owned()),
5227        "trusted" => Ok("Trusted".to_owned()),
5228        other => bail!("unsupported source trust level: {other}"),
5229    }
5230}
5231
5232fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
5233    SUPPORTED_ADMIN_SETTINGS
5234        .iter()
5235        .copied()
5236        .find(|setting| setting.eq_ignore_ascii_case(name))
5237        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
5238}
5239
5240fn default_source_trust_level() -> String {
5241    "None".to_owned()
5242}
5243
5244fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
5245where
5246    F: FnMut(&SqlRow) -> Result<T>,
5247{
5248    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5249        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5250    let mut result = Vec::new();
5251
5252    while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
5253        result.push(map(&row)?);
5254    }
5255
5256    Ok(result)
5257}
5258
5259fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
5260where
5261    F: FnOnce(&SqlRow) -> Result<T>,
5262{
5263    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5264        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5265
5266    match pollster::block_on(rows.next()).context("failed to read SQL row")? {
5267        Some(row) => Ok(Some(map(&row)?)),
5268        None => Ok(None),
5269    }
5270}
5271
5272fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
5273    pollster::block_on(connection.execute(sql, params_from_iter(params)))
5274        .with_context(|| format!("failed to execute SQL statement: {sql}"))
5275}
5276
5277fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
5278    pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
5279}
5280
5281fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
5282    row.get_value(index)
5283        .with_context(|| format!("failed to read SQL column {index}"))
5284}
5285
5286fn row_string(row: &SqlRow, index: usize) -> Result<String> {
5287    match row_value(row, index)? {
5288        SqlValue::Text(value) => Ok(value),
5289        SqlValue::Integer(value) => Ok(value.to_string()),
5290        SqlValue::Real(value) => Ok(value.to_string()),
5291        SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
5292        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5293    }
5294}
5295
5296fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5297    match row_value(row, index)? {
5298        SqlValue::Null => Ok(None),
5299        SqlValue::Text(value) => Ok(Some(value)),
5300        SqlValue::Integer(value) => Ok(Some(value.to_string())),
5301        SqlValue::Real(value) => Ok(Some(value.to_string())),
5302        SqlValue::Blob(value) => Ok(Some(
5303            String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
5304        )),
5305    }
5306}
5307
5308fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
5309    match row_value(row, index)? {
5310        SqlValue::Integer(value) => Ok(value),
5311        SqlValue::Text(value) => value
5312            .parse::<i64>()
5313            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
5314        SqlValue::Real(value) => f64_to_i64_checked(value, index),
5315        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5316        SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
5317    }
5318}
5319
5320fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
5321    match row_value(row, index)? {
5322        SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
5323        SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
5324        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5325        SqlValue::Integer(value) => Ok(format!("{value:x}")),
5326        SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
5327    }
5328}
5329
5330fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5331    match row_value(row, index)? {
5332        SqlValue::Null => Ok(None),
5333        SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
5334        SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
5335        SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
5336        SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
5337    }
5338}
5339
5340fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
5341    if !value.is_finite() {
5342        bail!("SQL column {index} contained a non-finite floating-point value");
5343    }
5344
5345    if value.fract() != 0.0 {
5346        bail!("SQL column {index} contained a non-integer floating-point value");
5347    }
5348
5349    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
5350        bail!("SQL column {index} contained a floating-point value outside the i64 range");
5351    }
5352
5353    format!("{value:.0}")
5354        .parse::<i64>()
5355        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
5356}
5357
5358fn bytes_to_hex(bytes: &[u8]) -> String {
5359    let mut result = String::with_capacity(bytes.len() * 2);
5360    for byte in bytes {
5361        result.push_str(&format!("{byte:02x}"));
5362    }
5363    result
5364}
5365
5366fn query_v2_matches(
5367    connection: &Connection,
5368    query: &PackageQuery,
5369    semantics: SearchSemantics,
5370) -> Result<(Vec<V2SearchRow>, bool)> {
5371    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
5372    let limit = source_fetch_results(query, semantics);
5373    let sql = format!(
5374        "SELECT rowid, id, name, moniker, latest_version, hash \
5375         FROM packages WHERE {where_clause} LIMIT {}",
5376        limit + 1
5377    );
5378    let mut rows = query_rows(
5379        connection,
5380        &sql,
5381        params.into_iter().map(SqlValue::Text).collect(),
5382        |row| {
5383            Ok(V2SearchRow {
5384                package_rowid: row_i64(row, 0)?,
5385                id: row_string(row, 1)?,
5386                name: row_string(row, 2)?,
5387                moniker: row_opt_string(row, 3)?,
5388                version: row_string(row, 4)?,
5389                package_hash: row_hex_string(row, 5)?,
5390                match_criteria: None,
5391            })
5392        },
5393    )?;
5394    for row in &mut rows {
5395        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
5396    }
5397    let truncated = rows.len() > limit;
5398    rows.truncate(limit);
5399    Ok((rows, truncated))
5400}
5401
5402fn query_v1_matches(
5403    connection: &Connection,
5404    query: &PackageQuery,
5405    semantics: SearchSemantics,
5406) -> Result<(Vec<V1SearchRow>, bool)> {
5407    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5408    let limit = source_fetch_results(query, semantics);
5409    let sql = format!(
5410        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5411         FROM manifest \
5412         JOIN ids ON manifest.id = ids.rowid \
5413         JOIN names ON manifest.name = names.rowid \
5414         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5415         JOIN versions ON manifest.version = versions.rowid \
5416         JOIN channels ON manifest.channel = channels.rowid \
5417         WHERE {where_clause} LIMIT {}",
5418        limit + 1
5419    );
5420    let mut rows = query_rows(
5421        connection,
5422        &sql,
5423        params.into_iter().map(SqlValue::Text).collect(),
5424        |row| {
5425            Ok(V1SearchRow {
5426                manifest_rowid: row_i64(row, 0)?,
5427                package_rowid: row_i64(row, 1)?,
5428                version: row_string(row, 2)?,
5429                channel: row_string(row, 3)?,
5430                id: row_string(row, 4)?,
5431                name: row_string(row, 5)?,
5432                moniker: row_opt_string(row, 6)?,
5433                match_criteria: None,
5434            })
5435        },
5436    )?;
5437    for row in &mut rows {
5438        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5439    }
5440    let truncated = rows.len() > limit;
5441    rows.truncate(limit);
5442    Ok((rows, truncated))
5443}
5444
5445fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5446    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5447
5448    for row in rows {
5449        match grouped.get(&row.package_rowid) {
5450            Some(existing)
5451                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5452                    != Ordering::Less => {}
5453            _ => {
5454                grouped.insert(row.package_rowid, row);
5455            }
5456        }
5457    }
5458
5459    grouped.into_values().collect()
5460}
5461
5462fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5463    let has_hash = table_has_column(connection, "manifest", "hash")?;
5464    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5465    let sql = format!(
5466        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5467         FROM manifest \
5468         JOIN versions ON manifest.version = versions.rowid \
5469         JOIN channels ON manifest.channel = channels.rowid \
5470         WHERE manifest.id = ?1"
5471    );
5472    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5473        Ok(V1VersionRow {
5474            version: row_string(row, 0)?,
5475            channel: row_string(row, 1)?,
5476            pathpart_id: row_i64(row, 2)?,
5477            manifest_hash: row_opt_hex_string(row, 3)?,
5478        })
5479    })
5480}
5481
5482fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5483    let mut parts = Vec::new();
5484    let mut current = Some(pathpart_id);
5485
5486    while let Some(id) = current {
5487        let (parent, pathpart) = query_optional_value(
5488            connection,
5489            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5490            vec![SqlValue::Integer(id)],
5491            |row| {
5492                Ok((
5493                    row_opt_string(row, 0)?
5494                        .and_then(|value| value.parse::<i64>().ok())
5495                        .filter(|value| *value != 0),
5496                    row_string(row, 1)?,
5497                ))
5498            },
5499        )?
5500        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5501        parts.push(pathpart);
5502        current = parent;
5503    }
5504
5505    parts.reverse();
5506    Ok(parts.join("/"))
5507}
5508
5509fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5510    format!(
5511        "packages/{}/{}/versionData.mszyml",
5512        package_id,
5513        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5514    )
5515}
5516
5517fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5518    let message = format!("{error:#}").to_ascii_lowercase();
5519    message.contains("no such table") && message.contains("packages")
5520}
5521
5522fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5523    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5524        row_string(row, 1)
5525    })? {
5526        if value.eq_ignore_ascii_case(column) {
5527            return Ok(true);
5528        }
5529    }
5530
5531    Ok(false)
5532}
5533
5534fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5535    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5536    let id_column = if v2 { "id" } else { "ids.id" };
5537    let name_column = if v2 { "name" } else { "names.name" };
5538    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5539    let exact_match = query.exact || semantics == SearchSemantics::Single;
5540    let mut params = Vec::new();
5541
5542    if let Some(value) = &query.id {
5543        return (
5544            single_field_condition(id_column, value, exact_match, &mut params),
5545            params,
5546        );
5547    }
5548    if let Some(value) = &query.name {
5549        return (
5550            single_field_condition(name_column, value, exact_match, &mut params),
5551            params,
5552        );
5553    }
5554    if let Some(value) = &query.moniker {
5555        return (
5556            single_field_condition(moniker_column, value, exact_match, &mut params),
5557            params,
5558        );
5559    }
5560    if let Some(value) = &query.tag {
5561        return (
5562            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5563            params,
5564        );
5565    }
5566    if let Some(value) = &query.command {
5567        return (
5568            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5569            params,
5570        );
5571    }
5572    if let Some(value) = &query.query {
5573        if exact_match {
5574            let conditions = [
5575                single_field_condition(id_column, value, true, &mut params),
5576                single_field_condition(name_column, value, true, &mut params),
5577                single_field_condition(moniker_column, value, true, &mut params),
5578            ];
5579            return (format!("({})", conditions.join(" OR ")), params);
5580        }
5581
5582        let mut conditions = vec![
5583            single_field_condition(id_column, value, false, &mut params),
5584            single_field_condition(name_column, value, false, &mut params),
5585            single_field_condition(moniker_column, value, false, &mut params),
5586        ];
5587        if semantics == SearchSemantics::Many {
5588            conditions.push(mapped_field_condition(
5589                v2,
5590                "tag",
5591                value,
5592                rowid_column,
5593                false,
5594                &mut params,
5595            ));
5596            conditions.push(mapped_field_condition(
5597                v2,
5598                "command",
5599                value,
5600                rowid_column,
5601                false,
5602                &mut params,
5603            ));
5604        }
5605        return (format!("({})", conditions.join(" OR ")), params);
5606    }
5607
5608    ("1 = 1".to_owned(), Vec::new())
5609}
5610
5611fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5612    params.push(match_parameter(value, exact));
5613    format!("{column} LIKE ?{}", params.len())
5614}
5615
5616fn mapped_field_condition(
5617    v2: bool,
5618    value_name: &str,
5619    value: &str,
5620    rowid_column: &str,
5621    exact: bool,
5622    params: &mut Vec<String>,
5623) -> String {
5624    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5625        (
5626            format!("{value_name}s2"),
5627            format!("{value_name}s2_map"),
5628            value_name.to_owned(),
5629            "package".to_owned(),
5630        )
5631    } else {
5632        (
5633            format!("{value_name}s"),
5634            format!("{value_name}s_map"),
5635            value_name.to_owned(),
5636            "manifest".to_owned(),
5637        )
5638    };
5639    params.push(match_parameter(value, exact));
5640    let parameter = params.len();
5641    format!(
5642        "EXISTS (SELECT 1 FROM {map_table_name} JOIN {table_name} ON \
5643         {map_table_name}.{value_name} = {table_name}.rowid \
5644         WHERE {map_table_name}.{map_owner_column} = {rowid_column} \
5645         AND {table_name}.{map_value_column} LIKE ?{parameter})"
5646    )
5647}
5648
5649fn match_parameter(value: &str, exact: bool) -> String {
5650    if exact { value.to_owned() } else { format!("%{value}%") }
5651}
5652
5653fn build_rest_search_body(
5654    query: &PackageQuery,
5655    info: &RestInformation,
5656    semantics: SearchSemantics,
5657) -> Result<JsonValue> {
5658    let mut root = serde_json::Map::new();
5659    root.insert(
5660        "MaximumResults".to_owned(),
5661        JsonValue::from(source_fetch_results(query, semantics) as u64),
5662    );
5663    let exact_match = query.exact || semantics == SearchSemantics::Single;
5664
5665    if let Some(value) = &query.query {
5666        if semantics == SearchSemantics::Single {
5667            let mut filters = vec![
5668                rest_filter("PackageIdentifier", value, true),
5669                rest_filter("PackageName", value, true),
5670                rest_filter("Moniker", value, true),
5671            ];
5672            append_required_rest_filters(&mut filters, info);
5673            root.insert("Filters".to_owned(), JsonValue::Array(filters));
5674            return Ok(JsonValue::Object(root));
5675        }
5676
5677        root.insert(
5678            "Query".to_owned(),
5679            serde_json::json!({
5680                "KeyWord": value,
5681                "MatchType": if exact_match { "Exact" } else { "Substring" },
5682            }),
5683        );
5684    }
5685
5686    let mut filters = Vec::new();
5687    if let Some(value) = &query.id {
5688        filters.push(rest_filter("PackageIdentifier", value, exact_match));
5689    }
5690    if let Some(value) = &query.name {
5691        filters.push(rest_filter("PackageName", value, exact_match));
5692    }
5693    if let Some(value) = &query.moniker {
5694        filters.push(rest_filter("Moniker", value, exact_match));
5695    }
5696    if let Some(value) = &query.tag {
5697        filters.push(rest_filter("Tag", value, true));
5698    }
5699    if let Some(value) = &query.command {
5700        filters.push(rest_filter("Command", value, true));
5701    }
5702    append_required_rest_filters(&mut filters, info);
5703
5704    if !filters.is_empty() {
5705        root.insert("Filters".to_owned(), JsonValue::Array(filters));
5706    }
5707
5708    Ok(JsonValue::Object(root))
5709}
5710
5711fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5712    if info
5713        .required_package_match_fields
5714        .iter()
5715        .any(|field| field.eq_ignore_ascii_case("Market"))
5716    {
5717        filters.push(rest_filter("Market", &default_market(), true));
5718    }
5719}
5720
5721fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5722    serde_json::json!({
5723        "PackageMatchField": field,
5724        "RequestMatch": {
5725            "KeyWord": value,
5726            "MatchType": if exact { "Exact" } else { "Substring" },
5727        }
5728    })
5729}
5730
5731fn max_results(query: &PackageQuery) -> usize {
5732    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5733}
5734
5735fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5736    match semantics {
5737        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5738        SearchSemantics::Single => max_results(query),
5739    }
5740}
5741
5742fn infer_preindexed_match_criteria_v2(
5743    connection: &Connection,
5744    row: &V2SearchRow,
5745    query: &PackageQuery,
5746    semantics: SearchSemantics,
5747) -> Result<Option<String>> {
5748    infer_match_criteria(
5749        &row.id,
5750        &row.name,
5751        row.moniker.as_deref(),
5752        query,
5753        semantics,
5754        |value| {
5755            find_mapped_value_v2(
5756                connection,
5757                "tags2",
5758                "tags2_map",
5759                "tag",
5760                row.package_rowid,
5761                value,
5762                query.exact,
5763            )
5764        },
5765        |value| {
5766            find_mapped_value_v2(
5767                connection,
5768                "commands2",
5769                "commands2_map",
5770                "command",
5771                row.package_rowid,
5772                value,
5773                query.exact,
5774            )
5775        },
5776    )
5777}
5778
5779fn infer_preindexed_match_criteria_v1(
5780    connection: &Connection,
5781    row: &V1SearchRow,
5782    query: &PackageQuery,
5783    semantics: SearchSemantics,
5784) -> Result<Option<String>> {
5785    infer_match_criteria(
5786        &row.id,
5787        &row.name,
5788        row.moniker.as_deref(),
5789        query,
5790        semantics,
5791        |value| {
5792            find_mapped_value_v1(
5793                connection,
5794                "tags",
5795                "tags_map",
5796                "tag",
5797                row.manifest_rowid,
5798                value,
5799                query.exact,
5800            )
5801        },
5802        |value| {
5803            find_mapped_value_v1(
5804                connection,
5805                "commands",
5806                "commands_map",
5807                "command",
5808                row.manifest_rowid,
5809                value,
5810                query.exact,
5811            )
5812        },
5813    )
5814}
5815
5816fn infer_match_criteria<FTag, FCommand>(
5817    id: &str,
5818    name: &str,
5819    moniker: Option<&str>,
5820    query: &PackageQuery,
5821    semantics: SearchSemantics,
5822    find_tag: FTag,
5823    find_command: FCommand,
5824) -> Result<Option<String>>
5825where
5826    FTag: FnOnce(&str) -> Result<Option<String>>,
5827    FCommand: FnOnce(&str) -> Result<Option<String>>,
5828{
5829    if let Some(value) = &query.tag {
5830        return Ok(Some(format_match_criteria("Tag", value)));
5831    }
5832    if let Some(value) = &query.command {
5833        return Ok(Some(format_match_criteria("Command", value)));
5834    }
5835    if let Some(value) = &query.moniker {
5836        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
5837    }
5838    if semantics == SearchSemantics::Single {
5839        return Ok(None);
5840    }
5841    if let Some(value) = &query.query {
5842        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
5843            return Ok(None);
5844        }
5845        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
5846            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
5847        }
5848        if let Some(tag) = find_tag(value)? {
5849            return Ok(Some(format_match_criteria("Tag", &tag)));
5850        }
5851        if let Some(command) = find_command(value)? {
5852            return Ok(Some(format_match_criteria("Command", &command)));
5853        }
5854    }
5855    Ok(None)
5856}
5857
5858fn find_mapped_value_v2(
5859    connection: &Connection,
5860    table_name: &str,
5861    map_table_name: &str,
5862    value_name: &str,
5863    package_rowid: i64,
5864    query: &str,
5865    exact: bool,
5866) -> Result<Option<String>> {
5867    let sql = format!(
5868        "SELECT {table_name}.{value_name} FROM {map_table_name} \
5869         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5870         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
5871    );
5872    let values = query_rows(
5873        connection,
5874        &sql,
5875        vec![
5876            SqlValue::Integer(package_rowid),
5877            SqlValue::Text(match_parameter(query, exact)),
5878        ],
5879        |row| row_string(row, 0),
5880    )?;
5881    Ok(select_best_text_match(values, query, exact))
5882}
5883
5884fn find_mapped_value_v1(
5885    connection: &Connection,
5886    table_name: &str,
5887    map_table_name: &str,
5888    value_name: &str,
5889    manifest_rowid: i64,
5890    query: &str,
5891    exact: bool,
5892) -> Result<Option<String>> {
5893    let sql = format!(
5894        "SELECT {table_name}.{value_name} FROM {map_table_name} \
5895         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5896         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
5897    );
5898    let values = query_rows(
5899        connection,
5900        &sql,
5901        vec![
5902            SqlValue::Integer(manifest_rowid),
5903            SqlValue::Text(match_parameter(query, exact)),
5904        ],
5905        |row| row_string(row, 0),
5906    )?;
5907    Ok(select_best_text_match(values, query, exact))
5908}
5909
5910fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
5911    if let Some(value) = &query.tag {
5912        return Some(format_match_criteria("Tag", value));
5913    }
5914    if let Some(value) = &query.command {
5915        return Some(format_match_criteria("Command", value));
5916    }
5917    if let Some(value) = &query.moniker {
5918        return Some(format_match_criteria("Moniker", value));
5919    }
5920    if semantics == SearchSemantics::Single {
5921        return None;
5922    }
5923    let value = query.query.as_deref()?;
5924    if json_string(item, "PackageIdentifier")
5925        .as_deref()
5926        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5927        || json_string(item, "PackageName")
5928            .as_deref()
5929            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5930    {
5931        return None;
5932    }
5933    json_string(item, "Moniker")
5934        .filter(|candidate| matches_text(candidate, value, query.exact))
5935        .map(|candidate| format_match_criteria("Moniker", &candidate))
5936}
5937
5938fn format_match_criteria(field: &str, value: &str) -> String {
5939    format!("{field}: {value}")
5940}
5941
5942fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
5943    if exact {
5944        candidate.eq_ignore_ascii_case(query)
5945    } else {
5946        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
5947    }
5948}
5949
5950fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
5951    let mut score = 0;
5952
5953    if let Some(value) = &query.query {
5954        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
5955        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
5956        if let Some(moniker) = candidate.moniker.as_deref() {
5957            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
5958        }
5959    }
5960
5961    if let Some(value) = &query.id {
5962        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
5963    }
5964    if let Some(value) = &query.name {
5965        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
5966    }
5967    if let Some(value) = &query.moniker
5968        && let Some(moniker) = candidate.moniker.as_deref()
5969    {
5970        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
5971    }
5972    if let Some(value) = &query.tag
5973        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5974    {
5975        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
5976    }
5977    if let Some(value) = &query.command
5978        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5979    {
5980        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
5981    }
5982
5983    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
5984        score += 5;
5985    }
5986    if search_match_has_unknown_version(candidate) {
5987        score = score.saturating_sub(10);
5988    }
5989
5990    score
5991}
5992
5993fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
5994    candidate
5995        .version
5996        .as_deref()
5997        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
5998}
5999
6000/// Builds the YAML manifest emitted next to the installer by `winget download`.
6001///
6002/// `documents` is the merged singleton JSON the source provided; we narrow the
6003/// `Installers` array down to just the resolved installer so the file describes
6004/// exactly the binary that was written, mirroring native winget behavior. The
6005/// typed `manifest` overrides identity fields (id, version, name, channel) on
6006/// the JSON because callers like `manifest_for_match` correct those after the
6007/// initial YAML parse.
6008fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
6009    let mut document = match documents {
6010        JsonValue::Object(map) => map.clone(),
6011        _ => serde_json::Map::new(),
6012    };
6013
6014    document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
6015    document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
6016    document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
6017    if !manifest.channel.is_empty() {
6018        document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
6019    } else {
6020        document.remove("Channel");
6021    }
6022
6023    // Replace the multi-installer array with the single resolved installer so
6024    // the emitted manifest describes the file the user actually downloaded.
6025    let mut single_installer = serde_json::Map::new();
6026    if let Some(arch) = installer.architecture.as_ref() {
6027        single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
6028    }
6029    if let Some(installer_type) = installer.installer_type.as_ref() {
6030        single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
6031    }
6032    if let Some(url) = installer.url.as_ref() {
6033        single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
6034    }
6035    if let Some(sha) = installer.sha256.as_ref() {
6036        single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
6037    }
6038    if let Some(scope) = installer.scope.as_ref() {
6039        single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
6040    }
6041    if let Some(locale) = installer.locale.as_ref() {
6042        single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
6043    }
6044    if let Some(product_code) = installer.product_code.as_ref() {
6045        single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
6046    }
6047    if let Some(release_date) = installer.release_date.as_ref() {
6048        single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
6049    }
6050
6051    document.insert(
6052        "Installers".to_owned(),
6053        JsonValue::Array(vec![JsonValue::Object(single_installer)]),
6054    );
6055    document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
6056
6057    let value = JsonValue::Object(document);
6058    serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
6059}
6060
6061/// Computes the file name `winget download` uses for the YAML manifest
6062/// emitted next to the installer:
6063/// `{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml`.
6064/// Mirrors `GetInstallerDownloadOnlyFileName` in winget-cli's DownloadFlow.
6065fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
6066    let mut filename = derive_download_filename_stem(manifest, installer);
6067    filename.push_str(".yaml");
6068    filename
6069}
6070
6071fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
6072    let mut stem = sanitize_filename_component(&manifest.name);
6073    if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
6074        stem.push('_');
6075        stem.push_str(&sanitize_filename_component(&manifest.version));
6076    }
6077    if let Some(scope) = installer.scope.as_deref() {
6078        stem.push('_');
6079        stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
6080    }
6081    let architecture = installer.architecture.as_deref().unwrap_or("neutral");
6082    stem.push('_');
6083    stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
6084
6085    let installer_type = installer.installer_type.as_deref().unwrap_or("");
6086    if !installer_type.is_empty() {
6087        stem.push('_');
6088        stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
6089    }
6090    if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
6091        stem.push('_');
6092        stem.push_str(&sanitize_filename_component(locale));
6093    }
6094
6095    stem
6096}
6097
6098fn capitalize_first(value: &str) -> String {
6099    let mut chars = value.chars();
6100    match chars.next() {
6101        Some(first) => {
6102            let mut out = first.to_ascii_uppercase().to_string();
6103            out.push_str(&chars.as_str().to_ascii_lowercase());
6104            out
6105        }
6106        None => String::new(),
6107    }
6108}
6109
6110fn format_architecture(value: &str) -> String {
6111    match value.to_ascii_lowercase().as_str() {
6112        "x64" => "X64".to_owned(),
6113        "x86" => "X86".to_owned(),
6114        "arm" => "Arm".to_owned(),
6115        "arm64" => "Arm64".to_owned(),
6116        "neutral" => "Neutral".to_owned(),
6117        other => capitalize_first(other),
6118    }
6119}
6120
6121fn sanitize_filename_component(value: &str) -> String {
6122    value
6123        .chars()
6124        .map(|ch| match ch {
6125            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
6126            ch if ch.is_control() => '_',
6127            ch => ch,
6128        })
6129        .collect()
6130}
6131
6132fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
6133    if manifest_path.is_file() {
6134        return Ok(manifest_path.to_path_buf());
6135    }
6136
6137    if !manifest_path.is_dir() {
6138        bail!("Manifest path not found: {}", manifest_path.display());
6139    }
6140
6141    let candidate = fs::read_dir(manifest_path)?
6142        .filter_map(|entry| entry.ok())
6143        .map(|entry| entry.path())
6144        .filter(|path| {
6145            path.extension()
6146                .and_then(|value| value.to_str())
6147                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
6148        })
6149        .min();
6150
6151    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
6152}
6153
6154fn score_text_match(
6155    candidate: &str,
6156    query: &str,
6157    exact: bool,
6158    exact_score: usize,
6159    prefix_score: usize,
6160    substring_score: usize,
6161) -> usize {
6162    if candidate.eq_ignore_ascii_case(query) {
6163        return exact_score;
6164    }
6165    if exact {
6166        return 0;
6167    }
6168
6169    let candidate_lower = candidate.to_ascii_lowercase();
6170    let query_lower = query.to_ascii_lowercase();
6171    if candidate_lower.starts_with(&query_lower) {
6172        prefix_score
6173    } else if candidate_lower.contains(&query_lower) {
6174        substring_score
6175    } else {
6176        0
6177    }
6178}
6179
6180fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
6181where
6182    I: IntoIterator<Item = String>,
6183{
6184    values
6185        .into_iter()
6186        .filter_map(|candidate| {
6187            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
6188            (score > 0).then_some((score, candidate))
6189        })
6190        .max_by(|(left_score, left_value), (right_score, right_value)| {
6191            left_score
6192                .cmp(right_score)
6193                .then_with(|| right_value.len().cmp(&left_value.len()))
6194                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
6195        })
6196        .map(|(_, candidate)| candidate)
6197}
6198
6199fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
6200    let (field, value) = criteria.split_once(": ")?;
6201    Some((field, value))
6202}
6203
6204fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
6205    let versions = item
6206        .get("Versions")
6207        .and_then(JsonValue::as_array)
6208        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
6209    let mut result = Vec::new();
6210    for version in versions {
6211        let value = json_string(version, "PackageVersion")
6212            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
6213        let channel = json_string(version, "Channel").unwrap_or_default();
6214        result.push(VersionKey {
6215            version: value,
6216            channel,
6217        });
6218    }
6219    Ok(result)
6220}
6221
6222fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
6223    let mut merged = YamlMapping::new();
6224    let mut documents = Vec::new();
6225    for document in serde_yaml::Deserializer::from_slice(bytes) {
6226        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
6227        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
6228        if let Some(mapping) = value.as_mapping() {
6229            merge_yaml_mapping(&mut merged, mapping);
6230        }
6231    }
6232
6233    let id = yaml_string_from_root(&merged, "PackageIdentifier")
6234        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
6235    let version =
6236        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
6237    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
6238
6239    let installers = parse_yaml_installers(&merged);
6240    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
6241    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6242
6243    Ok((
6244        Manifest {
6245            id,
6246            name,
6247            version,
6248            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
6249            publisher: yaml_localized_string(&merged, "Publisher"),
6250            description: yaml_localized_string(&merged, "Description")
6251                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
6252            moniker: yaml_string_from_root(&merged, "Moniker"),
6253            package_url: yaml_localized_string(&merged, "PackageUrl"),
6254            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
6255            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
6256            license: yaml_localized_string(&merged, "License"),
6257            license_url: yaml_localized_string(&merged, "LicenseUrl"),
6258            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
6259            author: yaml_localized_string(&merged, "Author"),
6260            copyright: yaml_localized_string(&merged, "Copyright"),
6261            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
6262            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
6263            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
6264            tags: yaml_string_list(&merged, "Tags"),
6265            agreements: yaml_agreement_list(&merged),
6266            package_dependencies: yaml_package_dependencies(&merged),
6267            documentation: yaml_documentation_list(&merged),
6268            installers,
6269            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6270        },
6271        collapse_structured_document(&JsonValue::Array(documents)),
6272    ))
6273}
6274
6275fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
6276    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
6277}
6278
6279fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
6280    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
6281    let data = root
6282        .get("Data")
6283        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
6284    let versions = data
6285        .get("Versions")
6286        .and_then(JsonValue::as_array)
6287        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
6288    let selected = versions
6289        .iter()
6290        .find(|item| {
6291            json_string(item, "PackageVersion").as_deref() == Some(version)
6292                && json_string(item, "Channel").unwrap_or_default() == channel
6293        })
6294        .or_else(|| versions.first())
6295        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
6296
6297    let default_locale = selected
6298        .get("DefaultLocale")
6299        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
6300    let name = json_string(default_locale, "PackageName")
6301        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
6302    let installer_switch_defaults = json_installer_switches(selected);
6303    let top_platforms = json_string_list(selected, "Platform");
6304    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
6305    let top_installer_type = json_string(selected, "InstallerType");
6306    let top_nested_installer_type = json_string(selected, "NestedInstallerType");
6307    let top_nested_installer_files = json_nested_installer_files(selected);
6308
6309    let installers = selected
6310        .get("Installers")
6311        .and_then(JsonValue::as_array)
6312        .map(|items| {
6313            items
6314                .iter()
6315                .map(|item| Installer {
6316                    platforms: {
6317                        let platforms = json_string_list(item, "Platform");
6318                        if platforms.is_empty() {
6319                            top_platforms.clone()
6320                        } else {
6321                            platforms
6322                        }
6323                    },
6324                    minimum_os_version: json_string(item, "MinimumOSVersion")
6325                        .or_else(|| top_minimum_os_version.clone()),
6326                    architecture: json_string(item, "Architecture"),
6327                    installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
6328                    nested_installer_type: json_string(item, "NestedInstallerType")
6329                        .or_else(|| top_nested_installer_type.clone()),
6330                    nested_installer_files: {
6331                        let files = json_nested_installer_files(item);
6332                        if files.is_empty() {
6333                            top_nested_installer_files.clone()
6334                        } else {
6335                            files
6336                        }
6337                    },
6338                    url: json_string(item, "InstallerUrl"),
6339                    sha256: json_string(item, "InstallerSha256"),
6340                    product_code: json_string(item, "ProductCode"),
6341                    locale: json_string(item, "InstallerLocale"),
6342                    scope: json_string(item, "Scope"),
6343                    release_date: json_string(item, "ReleaseDate"),
6344                    package_family_name: json_string(item, "PackageFamilyName"),
6345                    upgrade_code: json_string(item, "UpgradeCode"),
6346                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
6347                    commands: json_string_list(item, "Commands"),
6348                    package_dependencies: json_package_dependencies(item),
6349                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
6350                })
6351                .collect::<Vec<_>>()
6352        })
6353        .unwrap_or_default();
6354    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
6355    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6356
6357    let manifest = Manifest {
6358        id: package_id.to_owned(),
6359        name,
6360        version: version.to_owned(),
6361        channel: channel.to_owned(),
6362        publisher: json_string(default_locale, "Publisher"),
6363        description: json_string(default_locale, "Description")
6364            .or_else(|| json_string(default_locale, "ShortDescription")),
6365        moniker: json_string(default_locale, "Moniker"),
6366        package_url: json_string(default_locale, "PackageUrl"),
6367        publisher_url: json_string(default_locale, "PublisherUrl"),
6368        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
6369        license: json_string(default_locale, "License"),
6370        license_url: json_string(default_locale, "LicenseUrl"),
6371        privacy_url: json_string(default_locale, "PrivacyUrl"),
6372        author: json_string(default_locale, "Author"),
6373        copyright: json_string(default_locale, "Copyright"),
6374        copyright_url: json_string(default_locale, "CopyrightUrl"),
6375        release_notes: json_string(default_locale, "ReleaseNotes"),
6376        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
6377        tags: json_string_list(default_locale, "Tags"),
6378        agreements: json_agreement_list(default_locale),
6379        package_dependencies: json_package_dependencies(selected),
6380        documentation: json_documentation_list(default_locale),
6381        installers,
6382        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6383    };
6384
6385    Ok((
6386        manifest,
6387        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
6388    ))
6389}
6390
6391fn collapse_structured_document(document: &JsonValue) -> JsonValue {
6392    match document {
6393        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
6394            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
6395        }
6396        JsonValue::Array(documents) => merge_manifest_documents(documents),
6397        JsonValue::Object(_) if is_merged_manifest_document(document) => {
6398            merge_manifest_documents(&split_merged_manifest_document(document))
6399        }
6400        _ => document.clone(),
6401    }
6402}
6403
6404fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
6405    documents.iter().map(collapse_structured_document).collect()
6406}
6407
6408fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
6409    let version = documents.iter().find(|document| {
6410        document
6411            .get("ManifestType")
6412            .and_then(JsonValue::as_str)
6413            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
6414    });
6415    let default_locale = documents.iter().find(|document| {
6416        document
6417            .get("ManifestType")
6418            .and_then(JsonValue::as_str)
6419            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
6420    });
6421    let installer = documents.iter().find(|document| {
6422        document
6423            .get("ManifestType")
6424            .and_then(JsonValue::as_str)
6425            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
6426    });
6427
6428    let mut singleton = JsonMap::new();
6429    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
6430    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
6431    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
6432
6433    if !singleton.contains_key("PackageLocale")
6434        && let Some(package_locale) = default_locale
6435            .and_then(|document| json_string(document, "PackageLocale"))
6436            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
6437    {
6438        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6439    }
6440
6441    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
6442    singleton.insert(
6443        "ManifestVersion".to_owned(),
6444        JsonValue::String(
6445            installer
6446                .and_then(|document| json_string(document, "ManifestVersion"))
6447                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
6448                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
6449                .unwrap_or_else(|| "1.10.0".to_owned()),
6450        ),
6451    );
6452    singleton.remove("DefaultLocale");
6453
6454    JsonValue::Object(singleton)
6455}
6456
6457fn is_merged_manifest_document(document: &JsonValue) -> bool {
6458    document
6459        .get("ManifestType")
6460        .and_then(JsonValue::as_str)
6461        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
6462}
6463
6464fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
6465    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
6466    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
6467    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6468    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
6469
6470    let version_document = serde_json::json!({
6471        "PackageIdentifier": package_identifier,
6472        "PackageVersion": package_version,
6473        "DefaultLocale": package_locale,
6474        "ManifestType": "version",
6475        "ManifestVersion": manifest_version,
6476    });
6477
6478    let mut default_locale_document = project_manifest_document(
6479        merged,
6480        &[
6481            "PackageIdentifier",
6482            "PackageVersion",
6483            "PackageLocale",
6484            "Publisher",
6485            "PublisherUrl",
6486            "PublisherSupportUrl",
6487            "PrivacyUrl",
6488            "Author",
6489            "PackageName",
6490            "PackageUrl",
6491            "License",
6492            "LicenseUrl",
6493            "Copyright",
6494            "CopyrightUrl",
6495            "ShortDescription",
6496            "Description",
6497            "Moniker",
6498            "Tags",
6499            "Agreements",
6500            "ReleaseNotes",
6501            "ReleaseNotesUrl",
6502            "PurchaseUrl",
6503            "InstallationNotes",
6504            "Documentations",
6505            "Icons",
6506        ],
6507    );
6508    default_locale_document.insert(
6509        "PackageIdentifier".to_owned(),
6510        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6511    );
6512    default_locale_document.insert(
6513        "PackageVersion".to_owned(),
6514        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6515    );
6516    default_locale_document.insert(
6517        "PackageLocale".to_owned(),
6518        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
6519    );
6520    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6521    default_locale_document.insert(
6522        "ManifestVersion".to_owned(),
6523        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6524    );
6525
6526    let mut installer_document = project_manifest_document(
6527        merged,
6528        &[
6529            "PackageIdentifier",
6530            "PackageVersion",
6531            "Channel",
6532            "InstallerLocale",
6533            "Platform",
6534            "MinimumOSVersion",
6535            "InstallerType",
6536            "NestedInstallerType",
6537            "NestedInstallerFiles",
6538            "Scope",
6539            "InstallModes",
6540            "InstallerSwitches",
6541            "InstallerSuccessCodes",
6542            "ExpectedReturnCodes",
6543            "UpgradeBehavior",
6544            "Commands",
6545            "Protocols",
6546            "FileExtensions",
6547            "Dependencies",
6548            "PackageFamilyName",
6549            "ProductCode",
6550            "Capabilities",
6551            "RestrictedCapabilities",
6552            "Markets",
6553            "InstallerAbortsTerminal",
6554            "ReleaseDate",
6555            "InstallLocationRequired",
6556            "RequireExplicitUpgrade",
6557            "DisplayInstallWarnings",
6558            "UnsupportedOSArchitectures",
6559            "UnsupportedArguments",
6560            "AppsAndFeaturesEntries",
6561            "ElevationRequirement",
6562            "InstallationMetadata",
6563            "DownloadCommandProhibited",
6564            "RepairBehavior",
6565            "ArchiveBinariesDependOnPath",
6566            "Authentication",
6567            "Installers",
6568        ],
6569    );
6570    installer_document.insert(
6571        "PackageIdentifier".to_owned(),
6572        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6573    );
6574    installer_document.insert(
6575        "PackageVersion".to_owned(),
6576        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6577    );
6578    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6579    installer_document.insert(
6580        "ManifestVersion".to_owned(),
6581        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6582    );
6583
6584    vec![
6585        version_document,
6586        JsonValue::Object(default_locale_document),
6587        JsonValue::Object(installer_document),
6588    ]
6589}
6590
6591fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6592    let mut result = JsonMap::new();
6593    if let Some(source) = source.as_object() {
6594        for key in keys {
6595            if let Some(value) = source.get(*key) {
6596                result.insert((*key).to_owned(), value.clone());
6597            }
6598        }
6599    }
6600
6601    result
6602}
6603
6604fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6605    if let Some(source) = source.and_then(JsonValue::as_object) {
6606        for key in keys {
6607            if let Some(value) = source.get(*key) {
6608                target.insert((*key).to_owned(), value.clone());
6609            }
6610        }
6611    }
6612}
6613
6614fn copy_all_manifest_keys_except(
6615    target: &mut JsonMap<String, JsonValue>,
6616    source: Option<&JsonValue>,
6617    excluded_keys: &[&str],
6618) {
6619    if let Some(source) = source.and_then(JsonValue::as_object) {
6620        for (key, value) in source {
6621            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6622                target.insert(key.clone(), value.clone());
6623            }
6624        }
6625    }
6626}
6627
6628fn build_rest_manifest_documents(
6629    data: &JsonValue,
6630    installer_source: &JsonValue,
6631    default_locale: &JsonValue,
6632    package_id: &str,
6633    version: &str,
6634    channel: &str,
6635) -> JsonValue {
6636    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6637    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6638    let manifest_version = json_string(installer_source, "ManifestVersion")
6639        .or_else(|| json_string(default_locale, "ManifestVersion"))
6640        .or_else(|| json_string(data, "ManifestVersion"))
6641        .unwrap_or_else(|| "1.10.0".to_owned());
6642
6643    let mut version_document = JsonMap::new();
6644    version_document.insert(
6645        "PackageIdentifier".to_owned(),
6646        JsonValue::String(package_identifier.clone()),
6647    );
6648    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6649    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6650    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6651    version_document.insert(
6652        "ManifestVersion".to_owned(),
6653        JsonValue::String(manifest_version.clone()),
6654    );
6655
6656    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6657    default_locale_document.insert(
6658        "PackageIdentifier".to_owned(),
6659        JsonValue::String(package_identifier.clone()),
6660    );
6661    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6662    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6663    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6664    default_locale_document.insert(
6665        "ManifestVersion".to_owned(),
6666        JsonValue::String(manifest_version.clone()),
6667    );
6668
6669    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6670    installer_document.remove("DefaultLocale");
6671    installer_document.remove("Locales");
6672    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6673    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6674    if !channel.is_empty() {
6675        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6676    }
6677    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6678    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6679
6680    let documents = vec![
6681        JsonValue::Object(version_document),
6682        JsonValue::Object(default_locale_document),
6683        JsonValue::Object(installer_document),
6684    ];
6685
6686    merge_manifest_documents(&documents)
6687}
6688
6689fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6690    let base = installer_defaults(root);
6691    let base_switches = yaml_installer_switches(root);
6692    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6693        let installers = items
6694            .iter()
6695            .filter_map(YamlValue::as_mapping)
6696            .map(|item| {
6697                let mut merged = base.clone();
6698                merge_yaml_mapping(&mut merged, item);
6699                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6700                installer_from_yaml(&merged, switches)
6701            })
6702            .collect::<Vec<_>>();
6703        if !installers.is_empty() {
6704            return installers;
6705        }
6706    }
6707
6708    if base.is_empty() {
6709        Vec::new()
6710    } else {
6711        vec![installer_from_yaml(&base, base_switches)]
6712    }
6713}
6714
6715fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6716    let keys = [
6717        "Architecture",
6718        "InstallerType",
6719        "NestedInstallerType",
6720        "NestedInstallerFiles",
6721        "InstallerUrl",
6722        "InstallerSha256",
6723        "ProductCode",
6724        "InstallerLocale",
6725        "Platform",
6726        "MinimumOSVersion",
6727        "Scope",
6728        "ReleaseDate",
6729        "PackageFamilyName",
6730        "UpgradeCode",
6731        "Commands",
6732    ];
6733    let mut defaults = YamlMapping::new();
6734    for key in keys {
6735        if let Some(value) = root.get(YamlValue::from(key)) {
6736            defaults.insert(YamlValue::from(key), value.clone());
6737        }
6738    }
6739    defaults
6740}
6741
6742fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6743    Installer {
6744        architecture: yaml_string(root, "Architecture"),
6745        installer_type: yaml_string(root, "InstallerType"),
6746        nested_installer_type: yaml_string(root, "NestedInstallerType"),
6747        nested_installer_files: yaml_nested_installer_files(root),
6748        url: yaml_string(root, "InstallerUrl"),
6749        sha256: yaml_string(root, "InstallerSha256"),
6750        product_code: yaml_string(root, "ProductCode"),
6751        locale: yaml_string(root, "InstallerLocale"),
6752        scope: yaml_string(root, "Scope"),
6753        release_date: yaml_string(root, "ReleaseDate"),
6754        package_family_name: yaml_string(root, "PackageFamilyName"),
6755        upgrade_code: yaml_string(root, "UpgradeCode"),
6756        platforms: yaml_string_list(root, "Platform"),
6757        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6758        switches,
6759        commands: yaml_string_list(root, "Commands"),
6760        package_dependencies: yaml_package_dependencies(root),
6761        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6762    }
6763}
6764
6765/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
6766/// the legacy `True`/`False` variants the winget catalog mixes in.
6767fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6768    let Some(value) = root.get(YamlValue::from(key)) else {
6769        return false;
6770    };
6771    match value {
6772        YamlValue::Bool(b) => *b,
6773        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6774        _ => false,
6775    }
6776}
6777
6778fn json_bool(value: &JsonValue, key: &str) -> bool {
6779    match value.get(key) {
6780        Some(JsonValue::Bool(b)) => *b,
6781        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6782        _ => false,
6783    }
6784}
6785
6786fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
6787    let mapping = root
6788        .get(YamlValue::from("InstallerSwitches"))
6789        .or_else(|| root.get(YamlValue::from("Switches")))
6790        .and_then(YamlValue::as_mapping);
6791    yaml_installer_switches_from_mapping(mapping)
6792}
6793
6794fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
6795    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
6796
6797    InstallerSwitches {
6798        silent: string("Silent"),
6799        silent_with_progress: string("SilentWithProgress"),
6800        interactive: string("Interactive"),
6801        custom: string("Custom"),
6802        log: string("Log"),
6803        install_location: string("InstallLocation"),
6804    }
6805}
6806
6807fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
6808    yaml_string(root, key).or_else(|| {
6809        root.get(YamlValue::from("DefaultLocale"))
6810            .and_then(YamlValue::as_mapping)
6811            .and_then(|mapping| yaml_string(mapping, key))
6812    })
6813}
6814
6815fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
6816    yaml_string(root, key)
6817}
6818
6819fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
6820    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
6821}
6822
6823fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
6824    match value {
6825        YamlValue::Null => None,
6826        YamlValue::Bool(value) => Some(value.to_string()),
6827        YamlValue::Number(value) => Some(value.to_string()),
6828        YamlValue::String(value) => Some(value.clone()),
6829        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
6830        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
6831    }
6832}
6833
6834fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
6835    root.get(YamlValue::from(key))
6836        .and_then(YamlValue::as_sequence)
6837        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
6838        .unwrap_or_default()
6839}
6840
6841fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
6842    root.get(YamlValue::from("Documentations"))
6843        .and_then(YamlValue::as_sequence)
6844        .map(|items| {
6845            items
6846                .iter()
6847                .filter_map(YamlValue::as_mapping)
6848                .filter_map(|item| {
6849                    let url = yaml_string(item, "DocumentUrl")?;
6850                    Some(Documentation {
6851                        label: yaml_string(item, "DocumentLabel"),
6852                        url,
6853                    })
6854                })
6855                .collect()
6856        })
6857        .unwrap_or_default()
6858}
6859
6860fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
6861    root.get(YamlValue::from("Agreements"))
6862        .and_then(YamlValue::as_sequence)
6863        .map(|items| {
6864            items
6865                .iter()
6866                .filter_map(YamlValue::as_mapping)
6867                .map(|item| PackageAgreement {
6868                    label: yaml_string(item, "AgreementLabel"),
6869                    text: yaml_string(item, "Agreement"),
6870                    url: yaml_string(item, "AgreementUrl"),
6871                })
6872                .collect()
6873        })
6874        .unwrap_or_default()
6875}
6876
6877fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
6878    root.get(YamlValue::from("Dependencies"))
6879        .and_then(YamlValue::as_mapping)
6880        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
6881        .and_then(YamlValue::as_sequence)
6882        .map(|items| {
6883            items
6884                .iter()
6885                .filter_map(YamlValue::as_mapping)
6886                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
6887                .collect()
6888        })
6889        .unwrap_or_default()
6890}
6891
6892fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
6893    root.get(YamlValue::from("NestedInstallerFiles"))
6894        .and_then(YamlValue::as_sequence)
6895        .map(|items| {
6896            items
6897                .iter()
6898                .filter_map(YamlValue::as_mapping)
6899                .filter_map(|item| {
6900                    let relative = yaml_string(item, "RelativeFilePath")?;
6901                    Some(NestedInstallerFile {
6902                        relative_file_path: relative,
6903                        portable_command_alias: yaml_string(item, "PortableCommandAlias"),
6904                    })
6905                })
6906                .collect()
6907        })
6908        .unwrap_or_default()
6909}
6910
6911fn json_string(value: &JsonValue, key: &str) -> Option<String> {
6912    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
6913}
6914
6915fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
6916    value
6917        .get(key)
6918        .and_then(JsonValue::as_array)
6919        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
6920        .unwrap_or_default()
6921}
6922
6923fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
6924    value
6925        .get("Documentations")
6926        .and_then(JsonValue::as_array)
6927        .map(|items| {
6928            items
6929                .iter()
6930                .filter_map(|item| {
6931                    let url = json_string(item, "DocumentUrl")?;
6932                    Some(Documentation {
6933                        label: json_string(item, "DocumentLabel"),
6934                        url,
6935                    })
6936                })
6937                .collect()
6938        })
6939        .unwrap_or_default()
6940}
6941
6942fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
6943    value
6944        .get("Agreements")
6945        .and_then(JsonValue::as_array)
6946        .map(|items| {
6947            items
6948                .iter()
6949                .map(|item| PackageAgreement {
6950                    label: json_string(item, "AgreementLabel"),
6951                    text: json_string(item, "Agreement"),
6952                    url: json_string(item, "AgreementUrl"),
6953                })
6954                .collect()
6955        })
6956        .unwrap_or_default()
6957}
6958
6959fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
6960    value
6961        .get("Dependencies")
6962        .and_then(JsonValue::as_object)
6963        .and_then(|deps| deps.get("PackageDependencies"))
6964        .and_then(JsonValue::as_array)
6965        .map(|items| {
6966            items
6967                .iter()
6968                .filter_map(|item| json_string(item, "PackageIdentifier"))
6969                .collect()
6970        })
6971        .unwrap_or_default()
6972}
6973
6974fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
6975    value
6976        .get("NestedInstallerFiles")
6977        .and_then(JsonValue::as_array)
6978        .map(|items| {
6979            items
6980                .iter()
6981                .filter_map(|item| {
6982                    let relative = json_string(item, "RelativeFilePath")?;
6983                    Some(NestedInstallerFile {
6984                        relative_file_path: relative,
6985                        portable_command_alias: json_string(item, "PortableCommandAlias"),
6986                    })
6987                })
6988                .collect()
6989        })
6990        .unwrap_or_default()
6991}
6992
6993fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
6994    let switches = value
6995        .get("InstallerSwitches")
6996        .or_else(|| value.get("Switches"))
6997        .and_then(JsonValue::as_object);
6998
6999    let string = |key: &str| {
7000        switches
7001            .and_then(|switches| switches.get(key))
7002            .and_then(JsonValue::as_str)
7003            .map(str::to_owned)
7004    };
7005
7006    InstallerSwitches {
7007        silent: string("Silent"),
7008        silent_with_progress: string("SilentWithProgress"),
7009        interactive: string("Interactive"),
7010        custom: string("Custom"),
7011        log: string("Log"),
7012        install_location: string("InstallLocation"),
7013    }
7014}
7015
7016fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
7017    for (key, value) in source {
7018        target.insert(key.clone(), value.clone());
7019    }
7020}
7021
7022fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
7023    let reader = Cursor::new(bytes.to_vec());
7024    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
7025    let mut file = archive
7026        .by_name(member_name)
7027        .with_context(|| format!("zip payload missing {member_name}"))?;
7028    let mut output = Vec::new();
7029    file.read_to_end(&mut output)
7030        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
7031    Ok(output)
7032}
7033
7034fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
7035    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
7036        return Ok(output);
7037    }
7038
7039    if !bytes.starts_with(b"CK")
7040        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
7041    {
7042        return decompress_mszyml_payload(&bytes[offset + 2..]);
7043    }
7044
7045    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
7046}
7047
7048fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
7049    let mut decoder = flate2::read::DeflateDecoder::new(payload);
7050    let mut output = String::new();
7051    decoder.read_to_string(&mut output)?;
7052    Ok(output)
7053}
7054
7055fn cache_control_max_age(response: &Response) -> u64 {
7056    response
7057        .headers()
7058        .get(reqwest::header::CACHE_CONTROL)
7059        .and_then(|value| value.to_str().ok())
7060        .and_then(|header| {
7061            header.split(',').find_map(|part| {
7062                let trimmed = part.trim();
7063                trimmed
7064                    .strip_prefix("max-age=")
7065                    .and_then(|value| value.parse::<u64>().ok())
7066            })
7067        })
7068        .unwrap_or(60)
7069}
7070
7071fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
7072    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
7073        let (major, minor) = major_minor(candidate);
7074        server_versions.iter().any(|version| {
7075            let (server_major, server_minor) = major_minor(version);
7076            server_major == major && server_minor == minor
7077        })
7078    })
7079}
7080
7081fn major_minor(version: &str) -> (String, String) {
7082    let mut parts = version.split('.');
7083    (
7084        parts.next().unwrap_or_default().to_owned(),
7085        parts.next().unwrap_or_default().to_owned(),
7086    )
7087}
7088
7089fn default_market() -> String {
7090    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
7091}
7092
7093fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
7094    let requested_locale = query.locale.as_deref();
7095    let requested_architecture = query.installer_architecture.as_deref();
7096    let requested_type = query.installer_type.as_deref();
7097    let requested_scope = query.install_scope.as_deref();
7098    let requested_platform = query.platform.as_deref();
7099    let requested_os_version = query.os_version.as_deref();
7100    let system_architecture = current_architecture();
7101    let system_platform = current_platform();
7102    let current_os_version = current_os_version();
7103
7104    installers
7105        .iter()
7106        .enumerate()
7107        .filter(|(_, installer)| {
7108            installer_matches_requested(
7109                installer,
7110                requested_type,
7111                requested_scope,
7112                requested_platform,
7113                requested_os_version,
7114                system_platform,
7115                current_os_version.as_deref(),
7116            )
7117        })
7118        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
7119        .max_by_key(|(index, installer)| {
7120            (
7121                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
7122                std::cmp::Reverse(*index),
7123            )
7124        })
7125        .map(|(_, installer)| installer)
7126        .cloned()
7127}
7128
7129fn installer_matches_requested(
7130    installer: &Installer,
7131    requested_type: Option<&str>,
7132    requested_scope: Option<&str>,
7133    requested_platform: Option<&str>,
7134    requested_os_version: Option<&str>,
7135    system_platform: Option<&str>,
7136    current_os_version: Option<&str>,
7137) -> bool {
7138    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
7139        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
7140        && installer_matches_platform(installer, requested_platform, system_platform)
7141        && installer_matches_os_version(installer, requested_os_version, current_os_version)
7142}
7143
7144fn installer_matches_platform(
7145    installer: &Installer,
7146    requested_platform: Option<&str>,
7147    system_platform: Option<&str>,
7148) -> bool {
7149    if installer.platforms.is_empty() {
7150        return true;
7151    }
7152
7153    let Some(effective_platform) = requested_platform.or(system_platform) else {
7154        return true;
7155    };
7156
7157    installer
7158        .platforms
7159        .iter()
7160        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
7161}
7162
7163fn installer_matches_os_version(
7164    installer: &Installer,
7165    requested_os_version: Option<&str>,
7166    current_os_version: Option<&str>,
7167) -> bool {
7168    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
7169        return true;
7170    };
7171
7172    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
7173        return true;
7174    };
7175
7176    match (
7177        parse_dotted_version(minimum_os_version),
7178        parse_dotted_version(actual_os_version),
7179    ) {
7180        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
7181        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
7182    }
7183}
7184
7185fn installer_matches_architecture(
7186    installer: &Installer,
7187    requested_architecture: Option<&str>,
7188    system_architecture: &str,
7189) -> bool {
7190    let Some(architecture) = installer.architecture.as_deref() else {
7191        return true;
7192    };
7193
7194    let architecture = architecture.to_ascii_lowercase();
7195    if let Some(requested) = requested_architecture {
7196        return architecture.eq_ignore_ascii_case(requested);
7197    }
7198
7199    preferred_architectures(system_architecture)
7200        .iter()
7201        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
7202}
7203
7204fn installer_rank(
7205    installer: &Installer,
7206    requested_locale: Option<&str>,
7207    requested_architecture: Option<&str>,
7208    system_architecture: &str,
7209) -> (i32, i32, i32) {
7210    let architecture_rank = architecture_rank(
7211        installer.architecture.as_deref(),
7212        requested_architecture.unwrap_or(system_architecture),
7213        requested_architecture.is_some(),
7214        system_architecture,
7215    );
7216    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
7217    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
7218    (architecture_rank, locale_rank, command_rank)
7219}
7220
7221fn architecture_rank(
7222    installer_architecture: Option<&str>,
7223    preferred_architecture: &str,
7224    strict: bool,
7225    system_architecture: &str,
7226) -> i32 {
7227    let Some(value) = installer_architecture else {
7228        return 0;
7229    };
7230    if value.eq_ignore_ascii_case(preferred_architecture) {
7231        return 5;
7232    }
7233    if value.eq_ignore_ascii_case("neutral") {
7234        return 4;
7235    }
7236    if strict {
7237        return -1;
7238    }
7239
7240    preferred_architectures(system_architecture)
7241        .iter()
7242        .rev()
7243        .position(|candidate| value.eq_ignore_ascii_case(candidate))
7244        .and_then(|index| i32::try_from(index).ok())
7245        .and_then(|index| index.checked_add(1))
7246        .unwrap_or(-1)
7247}
7248
7249/// True when at least one installer in `manifest.installers` targets an
7250/// architecture the host can run. winget hides upgrades whose latest
7251/// version publishes no compatible installer.
7252fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
7253    if manifest.installers.is_empty() {
7254        return true;
7255    }
7256    let allowed = preferred_architectures(current_architecture());
7257    manifest.installers.iter().any(|installer| {
7258        let arch = installer.architecture.as_deref().unwrap_or("neutral");
7259        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
7260    })
7261}
7262
7263fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
7264    match system_architecture {
7265        "arm64" => &["arm64", "neutral", "x64", "x86"],
7266        "x64" => &["x64", "neutral", "x86"],
7267        "x86" => &["x86", "neutral"],
7268        _ => &["neutral"],
7269    }
7270}
7271
7272fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
7273    match (installer_locale, requested_locale) {
7274        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
7275        (Some(installer), Some(requested))
7276            if installer
7277                .split('-')
7278                .next()
7279                .zip(requested.split('-').next())
7280                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
7281        {
7282            2
7283        }
7284        (None, Some(_)) => 1,
7285        (Some(_), None) => 1,
7286        (None, None) => 0,
7287        _ => -1,
7288    }
7289}
7290
7291fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
7292    match requested {
7293        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
7294        None => true,
7295    }
7296}
7297
7298#[cfg(windows)]
7299fn current_architecture() -> &'static str {
7300    use std::sync::OnceLock;
7301    static CELL: OnceLock<&'static str> = OnceLock::new();
7302    CELL.get_or_init(|| {
7303        // IsWow64Process2's `native_machine` is the OS architecture even
7304        // when the process is running under emulation (x64 binary on
7305        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
7306        // mis-report the host arch in that case.
7307        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
7308        let mut process_machine: u16 = 0;
7309        let mut native_machine: u16 = 0;
7310        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
7311        // both out-pointers reference local stack u16s that outlive the call.
7312        let handle = unsafe { GetCurrentProcess() };
7313        // SAFETY: handle is valid per above; pointers are valid.
7314        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
7315        if ok != 0 {
7316            match native_machine {
7317                0xAA64 => return "arm64",
7318                0x8664 => return "x64",
7319                0x014C => return "x86",
7320                _ => {}
7321            }
7322        }
7323        compiled_architecture()
7324    })
7325}
7326
7327#[cfg(not(windows))]
7328fn current_architecture() -> &'static str {
7329    compiled_architecture()
7330}
7331
7332fn compiled_architecture() -> &'static str {
7333    match std::env::consts::ARCH {
7334        "x86_64" => "x64",
7335        "x86" => "x86",
7336        "aarch64" => "arm64",
7337        _ => "neutral",
7338    }
7339}
7340
7341fn current_platform() -> Option<&'static str> {
7342    if cfg!(windows) { Some("Windows.Desktop") } else { None }
7343}
7344
7345#[cfg(windows)]
7346fn current_os_version() -> Option<String> {
7347    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7348    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
7349    let build: String = key.get_value("CurrentBuildNumber").ok()?;
7350    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
7351    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
7352    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
7353    Some(format!("{major}.{minor}.{build}.{ubr}"))
7354}
7355
7356#[cfg(not(windows))]
7357fn current_os_version() -> Option<String> {
7358    None
7359}
7360
7361fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
7362    let mut parsed = Vec::new();
7363    for component in version.split('.') {
7364        if component.is_empty() {
7365            continue;
7366        }
7367        parsed.push(component.parse().ok()?);
7368    }
7369
7370    if parsed.is_empty() { None } else { Some(parsed) }
7371}
7372
7373fn select_v1_version(
7374    versions: &[V1VersionRow],
7375    requested_version: Option<&str>,
7376    requested_channel: Option<&str>,
7377    package_id: &str,
7378    source_name: &str,
7379) -> Result<V1VersionRow> {
7380    let mut ordered = versions.to_vec();
7381    ordered.sort_by(|left, right| {
7382        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7383    });
7384
7385    if let Some(version) = requested_version {
7386        let channel = requested_channel.unwrap_or_default();
7387        return ordered
7388            .iter()
7389            .find(|candidate| {
7390                candidate.version.eq_ignore_ascii_case(version)
7391                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7392            })
7393            .cloned()
7394            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7395    }
7396
7397    if let Some(channel) = requested_channel {
7398        return ordered
7399            .iter()
7400            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7401            .cloned()
7402            .ok_or_else(|| {
7403                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7404            });
7405    }
7406
7407    ordered
7408        .into_iter()
7409        .next()
7410        .ok_or_else(|| anyhow!("package had no versions"))
7411}
7412
7413fn select_v2_version(
7414    versions: &[PackageVersionDataEntry],
7415    requested_version: Option<&str>,
7416    requested_channel: Option<&str>,
7417    package_id: &str,
7418    source_name: &str,
7419) -> Result<PackageVersionDataEntry> {
7420    let mut ordered = versions.to_vec();
7421    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
7422
7423    if let Some(channel) = requested_channel {
7424        bail!(
7425            "Channel '{channel}' cannot be matched for package '{package_id}' in pre-indexed V2 source '{source_name}' because the local source index does not include channel metadata."
7426        );
7427    }
7428
7429    if let Some(version) = requested_version {
7430        return ordered
7431            .iter()
7432            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
7433            .cloned()
7434            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7435    }
7436
7437    ordered
7438        .into_iter()
7439        .next()
7440        .ok_or_else(|| anyhow!("package had no V2 versions"))
7441}
7442
7443fn missing_package_version(
7444    package_id: &str,
7445    source_name: &str,
7446    requested_version: &str,
7447    requested_channel: Option<&str>,
7448) -> anyhow::Error {
7449    anyhow!(MissingPackageVersionError {
7450        package_id: package_id.to_owned(),
7451        source_name: source_name.to_owned(),
7452        requested_version: requested_version.to_owned(),
7453        requested_channel: requested_channel.map(str::to_owned),
7454    })
7455}
7456
7457fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
7458    if let Some(refresh_error) = refresh.error.as_deref() {
7459        let missing_error = error.to_string();
7460        return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
7461    }
7462
7463    Err(error)
7464}
7465
7466fn select_rest_version<'a>(
7467    versions: &'a [VersionKey],
7468    requested_version: Option<&str>,
7469    requested_channel: Option<&str>,
7470    package_id: &str,
7471    source_name: &str,
7472) -> Result<&'a VersionKey> {
7473    if let Some(version) = requested_version {
7474        let channel = requested_channel.unwrap_or_default();
7475        return versions
7476            .iter()
7477            .find(|candidate| {
7478                candidate.version.eq_ignore_ascii_case(version)
7479                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7480            })
7481            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7482    }
7483
7484    if let Some(channel) = requested_channel {
7485        return versions
7486            .iter()
7487            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7488            .ok_or_else(|| {
7489                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7490            });
7491    }
7492
7493    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
7494}
7495
7496fn sort_versions_desc(versions: &mut [VersionKey]) {
7497    versions.sort_by(|left, right| {
7498        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7499    });
7500}
7501
7502fn compare_version_and_channel(
7503    left_version: &str,
7504    left_channel: &str,
7505    right_version: &str,
7506    right_channel: &str,
7507) -> Ordering {
7508    compare_version(left_version, right_version).then_with(|| {
7509        left_channel
7510            .to_ascii_lowercase()
7511            .cmp(&right_channel.to_ascii_lowercase())
7512    })
7513}
7514
7515fn compare_version(left: &str, right: &str) -> Ordering {
7516    let left_parts = tokenize_version(left);
7517    let right_parts = tokenize_version(right);
7518    let max_len = left_parts.len().max(right_parts.len());
7519
7520    for index in 0..max_len {
7521        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
7522        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
7523        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
7524
7525        let ordering = if let Some((left_number, right_number)) = numeric {
7526            left_number.cmp(&right_number)
7527        } else {
7528            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
7529        };
7530
7531        if ordering != Ordering::Equal {
7532            return ordering;
7533        }
7534    }
7535
7536    Ordering::Equal
7537}
7538
7539fn tokenize_version(value: &str) -> Vec<String> {
7540    let mut parts = Vec::new();
7541    let mut current = String::new();
7542    let mut current_is_digit = None;
7543
7544    for ch in value.chars() {
7545        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
7546            if !current.is_empty() {
7547                parts.push(std::mem::take(&mut current));
7548            }
7549            current_is_digit = None;
7550            continue;
7551        }
7552
7553        let is_digit = ch.is_ascii_digit();
7554        match current_is_digit {
7555            Some(previous) if previous != is_digit => {
7556                parts.push(std::mem::take(&mut current));
7557                current.push(ch);
7558                current_is_digit = Some(is_digit);
7559            }
7560            Some(_) => current.push(ch),
7561            None => {
7562                current.push(ch);
7563                current_is_digit = Some(is_digit);
7564            }
7565        }
7566    }
7567
7568    if !current.is_empty() {
7569        parts.push(current);
7570    }
7571
7572    parts
7573}
7574
7575fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
7576    if !hash_matches(Some(expected_hash), bytes) {
7577        bail!("downloaded file hash did not match expected SHA256");
7578    }
7579    Ok(())
7580}
7581
7582fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
7583    let Some(expected_hash) = expected_hash else {
7584        return Ok(());
7585    };
7586    if ignore_security_hash {
7587        return Ok(());
7588    }
7589
7590    if !hash_matches(Some(expected_hash), bytes) {
7591        let actual = sha256_hex(bytes);
7592        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
7593    }
7594    Ok(())
7595}
7596
7597fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
7598    expected_hash
7599        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
7600        .unwrap_or(true)
7601}
7602
7603fn sha256_hex(bytes: &[u8]) -> String {
7604    let digest = Sha256::digest(bytes);
7605    let mut output = String::with_capacity(digest.len() * 2);
7606    for byte in digest {
7607        output.push_str(&format!("{byte:02X}"));
7608    }
7609    output
7610}
7611
7612fn pins_db_path(app_root: &Path) -> PathBuf {
7613    if uses_packaged_layout(app_root) {
7614        app_root.join("pinning.db")
7615    } else {
7616        app_root.join("pins.db")
7617    }
7618}
7619
7620fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
7621    let mut has_current_column = false;
7622    let mut has_legacy_column = false;
7623
7624    for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
7625        match column_name.as_str() {
7626            "type" => has_current_column = true,
7627            "pin_type" => has_legacy_column = true,
7628            _ => {}
7629        }
7630    }
7631
7632    Ok(if has_current_column {
7633        Some("type")
7634    } else if has_legacy_column {
7635        Some("pin_type")
7636    } else {
7637        None
7638    })
7639}
7640
7641fn decode_pin_type(pin_type_int: i64) -> PinType {
7642    match pin_type_int {
7643        4 => PinType::Blocking,
7644        3 => PinType::Gating,
7645        _ => PinType::Pinning,
7646    }
7647}
7648
7649#[cfg(windows)]
7650fn dispatch_installer(
7651    installer_path: &Path,
7652    installer_type: &str,
7653    request: &InstallRequest,
7654    manifest: &Manifest,
7655    installer: &Installer,
7656) -> Result<i32> {
7657    use std::process::Command;
7658
7659    match installer_type {
7660        "msi" | "wix" => {
7661            let mut cmd = Command::new("msiexec");
7662            cmd.args(installer_command_arguments(
7663                installer_type,
7664                request,
7665                manifest,
7666                installer_path,
7667                installer,
7668            ));
7669            let status = cmd.status().context("failed to run msiexec")?;
7670            Ok(status.code().unwrap_or(-1))
7671        }
7672        "msix" | "appx" => {
7673            let mut cmd = Command::new("powershell");
7674            cmd.arg("-NoProfile")
7675                .arg("-Command")
7676                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7677            let status = cmd.status().context("failed to run Add-AppxPackage")?;
7678            Ok(status.code().unwrap_or(-1))
7679        }
7680        "zip" => {
7681            // A `zip` at the top-level installer position is treated as portable when
7682            // `NestedInstallerType=portable` is specified — that's the shape every
7683            // portable in the winget catalog uses (e.g. BurntSushi.ripgrep.MSVC).
7684            // Anything else still falls through to the historical extract-to-Programs
7685            // behavior, which isn't really correct but at least doesn't regress.
7686            if installer
7687                .nested_installer_type
7688                .as_deref()
7689                .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7690            {
7691                return install_portable(installer_path, request, manifest, installer);
7692            }
7693            let target = dirs::data_local_dir()
7694                .unwrap_or_else(|| PathBuf::from("."))
7695                .join("Programs");
7696            fs::create_dir_all(&target)?;
7697            let file = fs::File::open(installer_path)?;
7698            let mut archive = ZipArchive::new(file)?;
7699            archive.extract(&target)?;
7700            Ok(0)
7701        }
7702        "portable" => install_portable(installer_path, request, manifest, installer),
7703        // exe, inno, nullsoft, burn, etc.
7704        _ => {
7705            let mut cmd = Command::new(installer_path);
7706            cmd.args(installer_command_arguments(
7707                installer_type,
7708                request,
7709                manifest,
7710                installer_path,
7711                installer,
7712            ));
7713            let status = cmd.status().context("failed to run installer")?;
7714            Ok(status.code().unwrap_or(-1))
7715        }
7716    }
7717}
7718
7719#[cfg(not(windows))]
7720fn dispatch_installer(
7721    _installer_path: &Path,
7722    _installer_type: &str,
7723    _request: &InstallRequest,
7724    _manifest: &Manifest,
7725    _installer: &Installer,
7726) -> Result<i32> {
7727    bail!("Installing packages is only supported on Windows")
7728}
7729
7730#[cfg(any(windows, test))]
7731fn installer_command_arguments(
7732    installer_type: &str,
7733    request: &InstallRequest,
7734    _manifest: &Manifest,
7735    installer_path: &Path,
7736    installer: &Installer,
7737) -> Vec<String> {
7738    if let Some(override_args) = request.override_args.as_deref() {
7739        return split_installer_switches(override_args);
7740    }
7741
7742    let mut args = Vec::new();
7743    match installer_type {
7744        "msi" | "wix" => {
7745            args.push("/i".to_owned());
7746            args.push(installer_path.display().to_string());
7747        }
7748        "msix" | "appx" | "zip" => return Vec::new(),
7749        _ => {}
7750    }
7751
7752    let configured = match request.mode {
7753        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
7754        InstallerMode::SilentWithProgress => installer
7755            .switches
7756            .silent_with_progress
7757            .as_deref()
7758            .or(installer.switches.silent.as_deref()),
7759        InstallerMode::Silent => installer
7760            .switches
7761            .silent
7762            .as_deref()
7763            .or(installer.switches.silent_with_progress.as_deref()),
7764    };
7765
7766    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
7767        args.extend(split_installer_switches(value));
7768    } else {
7769        args.extend(default_installer_arguments(installer_type, request.mode));
7770    }
7771
7772    append_switches(
7773        &mut args,
7774        resolve_template_switch(
7775            installer.switches.log.as_deref(),
7776            default_log_switch(installer_type),
7777            request.log_path.as_ref().map(|path| path.display().to_string()),
7778            "<LOGPATH>",
7779        ),
7780    );
7781    append_switches(&mut args, installer.switches.custom.clone());
7782    append_switches(&mut args, request.custom.clone());
7783    append_switches(
7784        &mut args,
7785        resolve_template_switch(
7786            installer.switches.install_location.as_deref(),
7787            default_install_location_switch(installer_type),
7788            request.install_location.clone(),
7789            "<INSTALLPATH>",
7790        ),
7791    );
7792    args
7793}
7794
7795#[cfg(any(windows, test))]
7796fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
7797    match mode {
7798        InstallerMode::Interactive => Vec::new(),
7799        InstallerMode::SilentWithProgress => match installer_type {
7800            "inno" => vec![
7801                "/SP-".to_owned(),
7802                "/SILENT".to_owned(),
7803                "/SUPPRESSMSGBOXES".to_owned(),
7804                "/NORESTART".to_owned(),
7805            ],
7806            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
7807            "nullsoft" | "nsis" => vec!["/S".to_owned()],
7808            _ => vec!["/SILENT".to_owned()],
7809        },
7810        InstallerMode::Silent => match installer_type {
7811            "inno" => vec![
7812                "/SP-".to_owned(),
7813                "/VERYSILENT".to_owned(),
7814                "/SUPPRESSMSGBOXES".to_owned(),
7815                "/NORESTART".to_owned(),
7816            ],
7817            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
7818            "nullsoft" | "nsis" => vec!["/S".to_owned()],
7819            _ => vec!["/S".to_owned()],
7820        },
7821    }
7822}
7823
7824#[cfg(any(windows, test))]
7825fn default_log_switch(installer_type: &str) -> Option<&'static str> {
7826    match installer_type {
7827        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
7828        "inno" => Some("/LOG=\"<LOGPATH>\""),
7829        _ => None,
7830    }
7831}
7832
7833#[cfg(any(windows, test))]
7834fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
7835    match installer_type {
7836        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
7837        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
7838        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
7839        _ => None,
7840    }
7841}
7842
7843#[cfg(any(windows, test))]
7844fn resolve_template_switch(
7845    manifest_value: Option<&str>,
7846    fallback: Option<&str>,
7847    replacement: Option<String>,
7848    token: &str,
7849) -> Option<String> {
7850    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
7851    let replacement = replacement?;
7852    Some(template.replace(token, &replacement))
7853}
7854
7855#[cfg(any(windows, test))]
7856fn append_switches(args: &mut Vec<String>, value: Option<String>) {
7857    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
7858        args.extend(split_installer_switches(&value));
7859    }
7860}
7861
7862#[cfg(any(windows, test))]
7863fn split_installer_switches(value: &str) -> Vec<String> {
7864    let mut args = Vec::new();
7865    let mut current = String::new();
7866    let mut in_quotes = false;
7867
7868    for ch in value.chars() {
7869        match ch {
7870            '"' => in_quotes = !in_quotes,
7871            c if c.is_whitespace() && !in_quotes => {
7872                if !current.is_empty() {
7873                    args.push(std::mem::take(&mut current));
7874                }
7875            }
7876            _ => current.push(ch),
7877        }
7878    }
7879
7880    if !current.is_empty() {
7881        args.push(current);
7882    }
7883
7884    args
7885}
7886
7887#[cfg(windows)]
7888fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7889    if request.purge && request.preserve {
7890        bail!("--purge and --preserve cannot be used together.");
7891    }
7892
7893    if installed
7894        .installer_category
7895        .as_deref()
7896        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7897    {
7898        return uninstall_portable(installed, request);
7899    }
7900
7901    if (request.purge || request.preserve) && !request.force {
7902        bail!("--purge and --preserve are currently only supported for portable packages.");
7903    }
7904
7905    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
7906        return Ok(exit_code);
7907    }
7908
7909    if let Some(exit_code) = try_uninstall_msix(installed)? {
7910        return Ok(exit_code);
7911    }
7912
7913    bail!(
7914        "No uninstall command found for installed package '{}' ({})",
7915        installed.name,
7916        installed.local_id
7917    );
7918}
7919
7920#[cfg(windows)]
7921fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
7922    use std::os::windows::process::CommandExt;
7923    use std::process::Command;
7924
7925    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7926    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
7927
7928    let arp_paths = [
7929        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
7930        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
7931    ];
7932
7933    for root in [&hklm, &hkcu] {
7934        for arp_path in &arp_paths {
7935            if let Ok(key) = root.open_subkey(arp_path) {
7936                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
7937                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
7938                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
7939                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
7940                        if !registry_entry_matches_installed_package(
7941                            &subkey_name,
7942                            &display_name,
7943                            product_code.as_deref(),
7944                            installed,
7945                        ) {
7946                            continue;
7947                        }
7948
7949                        if let Some(exit_code) =
7950                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
7951                        {
7952                            return Ok(Some(exit_code));
7953                        }
7954
7955                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
7956                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
7957                            subkey.get_value("UninstallString").ok()
7958                        } else {
7959                            quiet_uninstall_cmd
7960                                .clone()
7961                                .or_else(|| subkey.get_value("UninstallString").ok())
7962                        }
7963                        .context("No uninstall command found in registry")?;
7964
7965                        if is_winget_uninstall_command(&uninstall_cmd)
7966                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
7967                        {
7968                            return Ok(Some(exit_code));
7969                        }
7970
7971                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
7972                        let uninstall_line = build_uninstall_command_with_mode(
7973                            &uninstall_cmd,
7974                            request.mode,
7975                            quiet_uninstall_cmd.is_some(),
7976                            log_path.as_deref(),
7977                        );
7978                        // `UninstallString` is a complete command line and may already contain
7979                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
7980                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
7981                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
7982                        // rejects with "is not recognized as an internal or external command".
7983                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
7984                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
7985                        // automatic quoting so we can feed cmd the literal command line.
7986                        let mut cmd = Command::new("cmd");
7987                        cmd.raw_arg("/S /C");
7988                        cmd.raw_arg(format!("\"{uninstall_line}\""));
7989                        let status = cmd.status().context("failed to run uninstaller")?;
7990                        return Ok(Some(status.code().unwrap_or(-1)));
7991                    }
7992                }
7993            }
7994        }
7995    }
7996
7997    Ok(None)
7998}
7999
8000#[cfg(windows)]
8001fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
8002    use std::process::Command;
8003
8004    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
8005        return Ok(None);
8006    }
8007
8008    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
8009    let mut cmd = Command::new("powershell");
8010    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
8011        package_full_name,
8012        &installed.package_family_names,
8013    ));
8014    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
8015    Ok(Some(status.code().unwrap_or(-1)))
8016}
8017
8018#[cfg(windows)]
8019fn arp_subkey_name(local_id: &str) -> Option<&str> {
8020    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
8021}
8022
8023#[cfg(windows)]
8024fn registry_entry_matches_installed_package(
8025    subkey_name: &str,
8026    display_name: &str,
8027    product_code: Option<&str>,
8028    installed: &ListMatch,
8029) -> bool {
8030    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
8031        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
8032    {
8033        return true;
8034    }
8035
8036    if installed.product_codes.iter().any(|code| {
8037        code.eq_ignore_ascii_case(subkey_name)
8038            || product_code
8039                .map(|value| code.eq_ignore_ascii_case(value))
8040                .unwrap_or(false)
8041    }) {
8042        return true;
8043    }
8044
8045    display_name.eq_ignore_ascii_case(&installed.name)
8046}
8047
8048#[cfg(all(windows, test))]
8049fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
8050    build_uninstall_command_with_mode(
8051        uninstall_cmd,
8052        if silent {
8053            InstallerMode::Silent
8054        } else {
8055            InstallerMode::Interactive
8056        },
8057        has_quiet_command,
8058        None,
8059    )
8060}
8061
8062#[cfg(windows)]
8063fn build_uninstall_command_with_mode(
8064    uninstall_cmd: &str,
8065    mode: InstallerMode,
8066    has_quiet_command: bool,
8067    log_path: Option<&str>,
8068) -> String {
8069    let uninstall_cmd = log_path
8070        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
8071        .unwrap_or_else(|| uninstall_cmd.to_owned());
8072    if mode == InstallerMode::Interactive || has_quiet_command {
8073        return uninstall_cmd;
8074    }
8075
8076    let lower = uninstall_cmd.to_ascii_lowercase();
8077    if is_winget_uninstall_command_lower(&lower) {
8078        return uninstall_cmd;
8079    }
8080    if lower.contains("/quiet")
8081        || lower.contains("/passive")
8082        || lower.contains("/verysilent")
8083        || lower.contains("/silent")
8084        || lower.contains(" /s")
8085    {
8086        return uninstall_cmd;
8087    }
8088
8089    format!("{uninstall_cmd} /S")
8090}
8091
8092#[cfg(windows)]
8093fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8094    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
8095        return Ok(None);
8096    };
8097
8098    let exit_code = run_winget_uninstall(&args)?;
8099    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
8100        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
8101            return Ok(Some(exit_code));
8102        };
8103
8104        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
8105    }
8106
8107    Ok(Some(exit_code))
8108}
8109
8110#[cfg(windows)]
8111fn run_winget_uninstall(args: &[String]) -> Result<i32> {
8112    use std::process::Command;
8113
8114    let status = Command::new("winget")
8115        .args(args)
8116        .status()
8117        .context("failed to run delegated winget uninstall")?;
8118    Ok(status.code().unwrap_or(-1))
8119}
8120
8121#[cfg(windows)]
8122fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
8123    build_winget_uninstall_arguments_with_scope(installed, request, true)
8124}
8125
8126#[cfg(windows)]
8127fn build_winget_uninstall_arguments_with_scope(
8128    installed: &ListMatch,
8129    request: &UninstallRequest,
8130    include_scope: bool,
8131) -> Option<Vec<String>> {
8132    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
8133        return None;
8134    }
8135
8136    let mut args = vec![
8137        "uninstall".to_owned(),
8138        "--id".to_owned(),
8139        installed.id.clone(),
8140        "--exact".to_owned(),
8141        "--disable-interactivity".to_owned(),
8142    ];
8143
8144    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
8145        args.push("--source".to_owned());
8146        args.push(source_name.to_owned());
8147    }
8148
8149    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
8150        args.push("--scope".to_owned());
8151        args.push(scope.to_owned());
8152    }
8153
8154    if request.mode == InstallerMode::Silent {
8155        args.push("--silent".to_owned());
8156    }
8157
8158    Some(args)
8159}
8160
8161#[cfg(windows)]
8162fn is_winget_uninstall_command(command: &str) -> bool {
8163    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
8164}
8165
8166#[cfg(windows)]
8167fn is_winget_uninstall_command_lower(command: &str) -> bool {
8168    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
8169}
8170
8171#[cfg(windows)]
8172fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
8173    let full_name_literal = package_full_name
8174        .map(|value| format!("'{}'", value.replace('\'', "''")))
8175        .unwrap_or_else(|| "$null".to_owned());
8176    let family_names_literal = if package_family_names.is_empty() {
8177        "@()".to_owned()
8178    } else {
8179        format!(
8180            "@({})",
8181            package_family_names
8182                .iter()
8183                .map(|value| format!("'{}'", value.replace('\'', "''")))
8184                .collect::<Vec<_>>()
8185                .join(", ")
8186        )
8187    };
8188
8189    format!(
8190        "$fullName = {full_name_literal}; \
8191         $familyNames = {family_names_literal}; \
8192         $targets = Get-AppxPackage | Where-Object {{ \
8193         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
8194         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
8195         if (-not $targets) {{ exit 1 }}; \
8196         $targets | Remove-AppxPackage"
8197    )
8198}
8199
8200#[cfg(windows)]
8201fn try_run_msi_uninstall(
8202    installed: &ListMatch,
8203    subkey_name: &str,
8204    product_code: Option<&str>,
8205    request: &UninstallRequest,
8206) -> Result<Option<i32>> {
8207    use std::process::Command;
8208
8209    let uninstall_code = installed
8210        .product_codes
8211        .iter()
8212        .map(String::as_str)
8213        .chain(product_code)
8214        .chain(Some(subkey_name))
8215        .find(|value| is_product_code_like(value));
8216
8217    let Some(uninstall_code) = uninstall_code else {
8218        return Ok(None);
8219    };
8220
8221    let mut cmd = Command::new("msiexec");
8222    cmd.arg("/x").arg(uninstall_code);
8223    match request.mode {
8224        InstallerMode::Interactive => {}
8225        InstallerMode::SilentWithProgress => {
8226            cmd.arg("/passive").arg("/norestart");
8227        }
8228        InstallerMode::Silent => {
8229            cmd.arg("/quiet").arg("/norestart");
8230        }
8231    }
8232    if let Some(log_path) = &request.log_path {
8233        cmd.arg("/log").arg(log_path);
8234    }
8235
8236    let status = cmd.status().context("failed to run msiexec uninstall")?;
8237    Ok(Some(status.code().unwrap_or(-1)))
8238}
8239
8240#[cfg(windows)]
8241fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
8242    // Read PortableSymlinkFullPath from the ARP entry *before* we touch the
8243    // registry, so we can remove winget-created shims in Links\ if any. Without
8244    // this, a portable that winget originally installed (with a
8245    // PortableCommandAlias set in its manifest) would leave a dangling symlink
8246    // in %LOCALAPPDATA%\Microsoft\WinGet\Links\ once pinget uninstalls it.
8247    let known_symlink = read_portable_symlink_full_path(installed);
8248
8249    let Some(location) = installed.install_location.as_deref() else {
8250        if request.force {
8251            try_remove_portable_symlinks(installed, &known_symlink, None);
8252            try_remove_portable_registry_entry(installed);
8253            return Ok(0);
8254        }
8255        bail!(
8256            "Portable package '{}' does not expose an install location.",
8257            installed.name
8258        );
8259    };
8260
8261    if request.preserve {
8262        return Ok(0);
8263    }
8264
8265    let path = Path::new(location);
8266    let removed = if path.is_dir() {
8267        fs::remove_dir_all(path)?;
8268        true
8269    } else if path.is_file() {
8270        fs::remove_file(path)?;
8271        true
8272    } else {
8273        false
8274    };
8275
8276    if removed || request.force {
8277        // Once the files are gone (or the user forced past missing files), drop
8278        // the symlink shim and ARP entry too so the package fully disappears.
8279        try_remove_portable_symlinks(installed, &known_symlink, Some(path));
8280        try_remove_portable_registry_entry(installed);
8281        return Ok(0);
8282    }
8283
8284    bail!("Portable package location not found: {location}")
8285}
8286
8287/// Reads `PortableSymlinkFullPath` from the ARP subkey backing `installed`
8288/// when present. This value is recorded for portable installs that created a
8289/// `Links\<alias>.exe` symlink, including pinget-installed entries when
8290/// `install_portable` created that link. Entries without a created/recorded
8291/// portable symlink return `None`.
8292#[cfg(windows)]
8293fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
8294    const ARP_PREFIX: &str = r"ARP\";
8295    if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
8296        let mut parts = rest.splitn(3, '\\');
8297        let scope = parts.next().unwrap_or("");
8298        let _arch = parts.next();
8299        let subkey_name = parts.next().unwrap_or("");
8300        if subkey_name.is_empty() {
8301            return None;
8302        }
8303        let hive = if scope.eq_ignore_ascii_case("User") {
8304            RegKey::predef(HKEY_CURRENT_USER)
8305        } else {
8306            RegKey::predef(HKEY_LOCAL_MACHINE)
8307        };
8308        let subkey = hive
8309            .open_subkey(format!(
8310                r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
8311            ))
8312            .ok()?;
8313        return read_reg_string(&subkey, "PortableSymlinkFullPath");
8314    }
8315    None
8316}
8317
8318/// Removes the symlink shims winget would have created for this portable.
8319/// Tries (1) the exact path from `PortableSymlinkFullPath` if known, and
8320/// (2) any symlink in `%LOCALAPPDATA%\Microsoft\WinGet\Links\` whose target
8321/// resolves under `install_location` (defensive sweep for shims winget made
8322/// but didn't surface in the registry). Best-effort: failures are swallowed.
8323#[cfg(windows)]
8324fn try_remove_portable_symlinks(
8325    _installed: &ListMatch,
8326    known_symlink: &Option<String>,
8327    install_location: Option<&Path>,
8328) {
8329    if let Some(path) = known_symlink.as_deref() {
8330        let _ = fs::remove_file(path);
8331    }
8332
8333    // Defensive sweep: walk Links\ and remove shims whose stored target was
8334    // inside (or equal to) install_location. By this point install_location is
8335    // already deleted, so we compare the symlink's stored target string (which
8336    // the filesystem retains even after the target itself is gone).
8337    let Some(install_dir) = install_location else { return };
8338
8339    let Some(local) = dirs::data_local_dir() else { return };
8340    let links_root = local.join("Microsoft").join("WinGet").join("Links");
8341    let Ok(entries) = fs::read_dir(&links_root) else { return };
8342    for entry in entries.flatten() {
8343        let path = entry.path();
8344        let Ok(target) = fs::read_link(&path) else { continue };
8345        if path_starts_with_case_insensitive(&target, install_dir) {
8346            let _ = fs::remove_file(&path);
8347        }
8348    }
8349}
8350
8351/// Case-insensitive, component-aware path-prefix check for Windows. A raw
8352/// `str::starts_with` on the path strings would let `C:\Foo` falsely match
8353/// `C:\Foobar`; iterating path components avoids that. Used by the Links\
8354/// sweep so an uninstall of one portable can't accidentally delete shims that
8355/// belong to a different portable whose install dir happens to share a
8356/// string-prefix.
8357#[cfg(windows)]
8358fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
8359    let lower_components = |p: &Path| -> Vec<String> {
8360        p.components()
8361            .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
8362            .collect()
8363    };
8364    let parent_components = lower_components(parent);
8365    let child_components = lower_components(child);
8366    if parent_components.is_empty() || parent_components.len() > child_components.len() {
8367        return false;
8368    }
8369    parent_components
8370        .iter()
8371        .zip(child_components.iter())
8372        .all(|(a, b)| a == b)
8373}
8374
8375/// Best-effort removal of the HKCU/HKLM ARP subkey backing a portable entry.
8376/// We match on local_id (`ARP\<scope>\<arch>\<subkey>`) when present so we
8377/// delete the exact key the list view surfaced; otherwise we walk the standard
8378/// Uninstall path and match on WinGetPackageIdentifier. Failures are logged via
8379/// the returned warning chain in callers — we never bubble a registry-cleanup
8380/// failure as a hard error because the file deletion already succeeded.
8381#[cfg(windows)]
8382fn try_remove_portable_registry_entry(installed: &ListMatch) {
8383    // Collect (is_hkcu, subkey_path) pairs so we can re-open hives at delete
8384    // time instead of holding clones of RegKey (which doesn't implement Clone).
8385    let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
8386        // local_id format is `ARP\<scope>\<arch>\<subkey>` — pick the right hive/path.
8387        let mut parts = rest.splitn(3, '\\');
8388        let scope = parts.next().unwrap_or("");
8389        let _arch = parts.next();
8390        let subkey_name = parts.next().unwrap_or("");
8391        if subkey_name.is_empty() {
8392            return;
8393        }
8394        let is_hkcu = scope.eq_ignore_ascii_case("User");
8395        vec![(
8396            is_hkcu,
8397            format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
8398        )]
8399    } else {
8400        // Fall back to scanning by WinGetPackageIdentifier.
8401        let mut found = Vec::new();
8402        for (is_hkcu, hive) in [
8403            (true, RegKey::predef(HKEY_CURRENT_USER)),
8404            (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
8405        ] {
8406            let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
8407                continue;
8408            };
8409            for name in uninstall.enum_keys().flatten() {
8410                let Ok(subkey) = uninstall.open_subkey(&name) else {
8411                    continue;
8412                };
8413                if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
8414                    found.push((
8415                        is_hkcu,
8416                        format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
8417                    ));
8418                }
8419            }
8420        }
8421        found
8422    };
8423
8424    for (is_hkcu, path) in candidates {
8425        let hive = if is_hkcu {
8426            RegKey::predef(HKEY_CURRENT_USER)
8427        } else {
8428            RegKey::predef(HKEY_LOCAL_MACHINE)
8429        };
8430        let _ = hive.delete_subkey_all(&path);
8431    }
8432}
8433
8434#[cfg(windows)]
8435fn is_product_code_like(value: &str) -> bool {
8436    value.starts_with('{') && value.ends_with('}')
8437}
8438
8439#[cfg(not(windows))]
8440fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
8441    bail!("Uninstalling packages is only supported on Windows")
8442}
8443
8444/// Installs (or upgrades) a portable WinGet package.
8445///
8446/// Handles both `InstallerType: portable` (single binary download) and
8447/// `InstallerType: zip` with `NestedInstallerType: portable` (zip containing
8448/// the binary at a relative path), which is the shape every portable in the
8449/// community winget catalog uses today.
8450///
8451/// Behavior matches winget's portable workflow closely enough that the resulting
8452/// HKCU ARP entry is recognized by `winget list` and by pinget's own list view:
8453/// - Resolves the target install directory in this priority order:
8454///   1. `request.install_location` if provided (passed for upgrades)
8455///   2. Existing `InstallLocation` from the registry ARP entry (so upgrades
8456///      with no explicit location still preserve the user's custom path)
8457///   3. WinGet's default user portable root, `%LOCALAPPDATA%\Microsoft\WinGet
8458///      \Packages\<PackageId>_<SourceId>`
8459/// - If an existing pinget-owned install lives in that directory
8460///   (`InstallDirectoryCreated=1`), cleans its contents before extracting the
8461///   new version so leftover files from older versions don't pile up.
8462/// - Extracts the zip (or copies the standalone binary), then writes the ARP
8463///   registry entry that `winget list` and pinget's `collect_uninstall_view`
8464///   both read from.
8465#[cfg(windows)]
8466fn install_portable(
8467    installer_path: &Path,
8468    request: &InstallRequest,
8469    manifest: &Manifest,
8470    installer: &Installer,
8471) -> Result<i32> {
8472    let source_identifier = portable_source_identifier(&request.query);
8473    let existing_entry = read_existing_portable_entry(&manifest.id);
8474    // Reuse the existing subkey name on upgrade so we don't orphan the prior
8475    // entry by writing to a different one.
8476    let subkey_name = existing_entry
8477        .as_ref()
8478        .map(|entry| entry.subkey_name.clone())
8479        .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
8480
8481    let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
8482
8483    // If we created this directory on a previous install, wipe it clean so the
8484    // new version doesn't co-exist with leftovers from the previous one. We do
8485    // not touch a directory we don't own (InstallDirectoryCreated != 1), to
8486    // avoid stomping user data.
8487    let prev_dir_created = existing_entry
8488        .as_ref()
8489        .and_then(|entry| entry.install_directory_created)
8490        == Some(1);
8491    let dir_existed = target_dir.is_dir();
8492    if prev_dir_created && dir_existed {
8493        clean_directory_contents(&target_dir).with_context(|| {
8494            format!(
8495                "failed to clean existing portable install directory: {}",
8496                target_dir.display()
8497            )
8498        })?;
8499    }
8500
8501    fs::create_dir_all(&target_dir)
8502        .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
8503    // The "we created it" flag stays sticky across upgrades — if a previous
8504    // install marked it created, it still counts as created by us even when
8505    // the dir already existed this round.
8506    let install_directory_created = !dir_existed || prev_dir_created;
8507
8508    let installer_type = installer.installer_type.as_deref().unwrap_or("");
8509    let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
8510        let file = fs::File::open(installer_path)
8511            .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
8512        let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
8513        archive
8514            .extract(&target_dir)
8515            .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
8516        // For nested-portable zips the binary lives at the RelativeFilePath the
8517        // manifest declares. We record the first one as PortableTargetFullPath so
8518        // winget's portable uninstall workflow can identify which file to remove.
8519        installer
8520            .nested_installer_files
8521            .first()
8522            .map(|file| target_dir.join(&file.relative_file_path))
8523            .unwrap_or_else(|| target_dir.clone())
8524    } else {
8525        // Standalone portable: copy the downloaded file into target_dir using
8526        // its original filename. winget would optionally rename to the
8527        // PortableCommandAlias; pinget keeps the original name for now.
8528        let basename = installer_path
8529            .file_name()
8530            .ok_or_else(|| anyhow!("portable installer has no filename"))?;
8531        let copied = target_dir.join(basename);
8532        fs::copy(installer_path, &copied)
8533            .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
8534        copied
8535    };
8536
8537    // Create the Links\<alias>.exe shim so users can invoke the portable from
8538    // any shell, matching winget's portable workflow. Failures are non-fatal —
8539    // when the user lacks SeCreateSymbolicLink (no Developer Mode, not admin)
8540    // we still leave the package installed at install_location.
8541    let alias = determine_portable_alias(installer);
8542    let symlink_full_path = match alias.as_deref() {
8543        Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
8544        _ => None,
8545    };
8546
8547    // Ensure %LOCALAPPDATA%\Microsoft\WinGet\Links is on user PATH so the
8548    // symlink we just created is resolvable from new shells. Tracks whether we
8549    // added it this round so uninstall can decide whether to take it back out.
8550    let added_to_path = if symlink_full_path.is_some() {
8551        try_add_links_dir_to_user_path()
8552    } else {
8553        false
8554    };
8555
8556    write_portable_arp_entry(&PortableArpEntry {
8557        subkey_name: &subkey_name,
8558        install_location: &target_dir,
8559        portable_target_full_path: &portable_target_full_path,
8560        portable_symlink_full_path: symlink_full_path.as_deref(),
8561        install_directory_created,
8562        added_to_path,
8563        source_identifier: &source_identifier,
8564        manifest,
8565    })?;
8566
8567    Ok(0)
8568}
8569
8570#[cfg(windows)]
8571struct PortableArpEntry<'a> {
8572    subkey_name: &'a str,
8573    install_location: &'a Path,
8574    portable_target_full_path: &'a Path,
8575    portable_symlink_full_path: Option<&'a Path>,
8576    install_directory_created: bool,
8577    added_to_path: bool,
8578    source_identifier: &'a str,
8579    manifest: &'a Manifest,
8580}
8581
8582/// Picks the portable command alias from the manifest. Mirrors winget's
8583/// resolution: nested-installer files first (used by zip+portable manifests),
8584/// then the top-level `Commands` field (used by standalone portable manifests).
8585/// Returns None when the manifest doesn't declare a command — the package
8586/// still installs, just without a Links\ shim.
8587#[cfg(windows)]
8588fn determine_portable_alias(installer: &Installer) -> Option<String> {
8589    installer
8590        .nested_installer_files
8591        .iter()
8592        .find_map(|file| file.portable_command_alias.clone())
8593        .or_else(|| installer.commands.first().cloned())
8594}
8595
8596#[cfg(windows)]
8597fn winget_links_dir() -> PathBuf {
8598    dirs::data_local_dir()
8599        .unwrap_or_else(|| PathBuf::from("."))
8600        .join("Microsoft")
8601        .join("WinGet")
8602        .join("Links")
8603}
8604
8605/// Creates (or replaces) `Links\<alias>.exe` as a file symlink pointing at the
8606/// portable's binary. Returns the link path on success. Returns None and logs
8607/// a warning if the user can't create symlinks (no Developer Mode, not admin)
8608/// so the install can still complete without the shim.
8609#[cfg(windows)]
8610fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
8611    use std::os::windows::fs::symlink_file;
8612    let links_dir = winget_links_dir();
8613    if fs::create_dir_all(&links_dir).is_err() {
8614        return None;
8615    }
8616    let link_path = links_dir.join(format!("{alias}.exe"));
8617
8618    // Replace any prior link/file at the path so upgrades repoint to the new
8619    // binary instead of leaving a stale symlink behind.
8620    if link_path.symlink_metadata().is_ok() {
8621        let _ = fs::remove_file(&link_path);
8622    }
8623
8624    // Symlink creation fails with ERROR_PRIVILEGE_NOT_HELD (1314) when the
8625    // process lacks SeCreateSymbolicLink (no Developer Mode, not admin). Treat
8626    // that — and any other I/O failure — as non-fatal: the package files are
8627    // already in place, just without a Links\ shim.
8628    symlink_file(target, &link_path).ok().map(|()| link_path)
8629}
8630
8631/// Adds `Links\` to the user's HKCU\Environment\Path if not already present,
8632/// then broadcasts WM_SETTINGCHANGE so explorer-spawned shells pick up the
8633/// new PATH without a logoff. Returns true if we actually appended PATH this
8634/// call — callers should write `InstallDirectoryAddedToPath` accordingly so a
8635/// follow-up uninstall doesn't take PATH back out for other portables.
8636#[cfg(windows)]
8637fn try_add_links_dir_to_user_path() -> bool {
8638    let links_dir = winget_links_dir();
8639    let links_str = links_dir.to_string_lossy().to_string();
8640    let normalized_links = links_str.to_ascii_lowercase();
8641
8642    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8643    let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
8644        return false;
8645    };
8646    let existing: String = env_key.get_value("Path").unwrap_or_default();
8647    let already_present = existing
8648        .split(';')
8649        .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
8650    if already_present {
8651        return false;
8652    }
8653
8654    let new_path = if existing.is_empty() {
8655        links_str
8656    } else if existing.ends_with(';') {
8657        format!("{existing}{links_str}")
8658    } else {
8659        format!("{existing};{links_str}")
8660    };
8661
8662    if env_key.set_value("Path", &new_path).is_err() {
8663        return false;
8664    }
8665
8666    broadcast_environment_change();
8667    true
8668}
8669
8670#[cfg(windows)]
8671fn broadcast_environment_change() {
8672    use std::ffi::OsStr;
8673    use std::os::windows::ffi::OsStrExt;
8674
8675    use windows_sys::Win32::UI::WindowsAndMessaging::{
8676        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
8677    };
8678
8679    let param: Vec<u16> = OsStr::new("Environment")
8680        .encode_wide()
8681        .chain(std::iter::once(0))
8682        .collect();
8683    // SAFETY: SendMessageTimeoutW only reads `param` for the duration of the
8684    // call (it copies the string before returning), and `result` is a stack
8685    // local we provide. No aliasing or lifetime issues.
8686    unsafe {
8687        let mut result: usize = 0;
8688        SendMessageTimeoutW(
8689            HWND_BROADCAST,
8690            WM_SETTINGCHANGE,
8691            0usize,
8692            param.as_ptr() as isize,
8693            SMTO_ABORTIFHUNG,
8694            5000,
8695            &mut result,
8696        );
8697    }
8698}
8699
8700/// Source identifier embedded in the ARP subkey name. We mirror winget for
8701/// the community repo so winget's UninstallString can resolve the subkey and
8702/// `winget list` still finds the package; everything else uses the source
8703/// name verbatim.
8704#[cfg(windows)]
8705fn portable_source_identifier(query: &PackageQuery) -> String {
8706    match query.source.as_deref() {
8707        Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8708        Some(name) if !name.is_empty() => name.to_owned(),
8709        _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8710    }
8711}
8712
8713#[cfg(windows)]
8714fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
8715    format!("{package_id}_{source_identifier}")
8716}
8717
8718#[cfg(windows)]
8719struct ExistingPortableEntry {
8720    install_location: Option<PathBuf>,
8721    install_directory_created: Option<u32>,
8722    subkey_name: String,
8723}
8724
8725#[cfg(windows)]
8726fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
8727    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8728    let uninstall = hkcu
8729        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
8730        .ok()?;
8731    for name in uninstall.enum_keys().flatten() {
8732        let Ok(subkey) = uninstall.open_subkey(&name) else {
8733            continue;
8734        };
8735        if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
8736            continue;
8737        }
8738        if !read_reg_string(&subkey, "WinGetInstallerType")
8739            .as_deref()
8740            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8741        {
8742            continue;
8743        }
8744        return Some(ExistingPortableEntry {
8745            install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
8746            install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
8747            subkey_name: name,
8748        });
8749    }
8750    None
8751}
8752
8753#[cfg(windows)]
8754fn resolve_portable_install_location(
8755    request: &InstallRequest,
8756    existing: &Option<ExistingPortableEntry>,
8757    subkey_name: &str,
8758) -> PathBuf {
8759    resolve_portable_install_location_pure(
8760        request.install_location.as_deref(),
8761        existing
8762            .as_ref()
8763            .and_then(|e| e.install_location.as_deref())
8764            .and_then(Path::to_str),
8765        subkey_name,
8766        &default_user_portable_root(),
8767    )
8768}
8769
8770/// Pure resolution rule for the portable install directory. Priority:
8771/// `request_location` (CLI override) → `existing_location` (so upgrades
8772/// preserve a winget-installed location) → default user portable root joined
8773/// with the package's ARP subkey name.
8774#[cfg(windows)]
8775fn resolve_portable_install_location_pure(
8776    request_location: Option<&str>,
8777    existing_location: Option<&str>,
8778    subkey_name: &str,
8779    default_user_portable_root: &Path,
8780) -> PathBuf {
8781    if let Some(custom) = request_location
8782        && !custom.is_empty()
8783    {
8784        return PathBuf::from(custom);
8785    }
8786    if let Some(loc) = existing_location
8787        && !loc.is_empty()
8788    {
8789        return PathBuf::from(loc);
8790    }
8791    default_user_portable_root.join(subkey_name)
8792}
8793
8794#[cfg(windows)]
8795fn default_user_portable_root() -> PathBuf {
8796    dirs::data_local_dir()
8797        .unwrap_or_else(|| PathBuf::from("."))
8798        .join("Microsoft")
8799        .join("WinGet")
8800        .join("Packages")
8801}
8802
8803#[cfg(windows)]
8804fn clean_directory_contents(dir: &Path) -> Result<()> {
8805    if !dir.is_dir() {
8806        return Ok(());
8807    }
8808    for entry in fs::read_dir(dir)? {
8809        let entry = entry?;
8810        let path = entry.path();
8811        if path.is_dir() {
8812            fs::remove_dir_all(&path)?;
8813        } else {
8814            fs::remove_file(&path)?;
8815        }
8816    }
8817    Ok(())
8818}
8819
8820/// Writes the HKCU ARP entry winget would normally write for a portable
8821/// install. Only the subset that winget's portable list view and pinget's
8822/// `collect_uninstall_view` actually read.
8823#[cfg(windows)]
8824fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
8825    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8826    let uninstall_path = format!(
8827        r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
8828        entry.subkey_name
8829    );
8830    let (subkey, _) = hkcu
8831        .create_subkey(&uninstall_path)
8832        .context("failed to create portable ARP registry subkey")?;
8833
8834    let manifest = entry.manifest;
8835    let install_location_str = entry.install_location.to_string_lossy().to_string();
8836    subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
8837    subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
8838    subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
8839    subkey.set_value("InstallLocation", &install_location_str)?;
8840    subkey.set_value(
8841        "PortableTargetFullPath",
8842        &entry.portable_target_full_path.to_string_lossy().to_string(),
8843    )?;
8844    if let Some(symlink_path) = entry.portable_symlink_full_path {
8845        subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
8846    }
8847    subkey.set_value(
8848        "InstallDirectoryAddedToPath",
8849        &(if entry.added_to_path { 1u32 } else { 0u32 }),
8850    )?;
8851    subkey.set_value(
8852        "InstallDirectoryCreated",
8853        &(if entry.install_directory_created { 1u32 } else { 0u32 }),
8854    )?;
8855    subkey.set_value(
8856        "DisplayName",
8857        &if manifest.name.is_empty() {
8858            manifest.id.clone()
8859        } else {
8860            manifest.name.clone()
8861        },
8862    )?;
8863    subkey.set_value("DisplayVersion", &manifest.version)?;
8864    if let Some(publisher) = manifest.publisher.as_ref() {
8865        subkey.set_value("Publisher", publisher)?;
8866    }
8867    subkey.set_value(
8868        "UninstallString",
8869        &format!("winget uninstall --product-code {}", entry.subkey_name),
8870    )?;
8871    let today = Utc::now().format("%Y%m%d").to_string();
8872    subkey.set_value("InstallDate", &today)?;
8873    if let Some(url) = manifest.package_url.as_ref() {
8874        subkey.set_value("URLInfoAbout", url)?;
8875    }
8876
8877    Ok(())
8878}
8879
8880#[cfg(test)]
8881mod tests {
8882    use std::io::{Read as _, Write};
8883    use std::net::{SocketAddr, TcpListener, TcpStream};
8884    use std::path::{Path, PathBuf};
8885    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
8886    use std::thread::{self, JoinHandle};
8887
8888    use flate2::Compression;
8889    use flate2::write::DeflateEncoder;
8890    use zip::write::SimpleFileOptions;
8891
8892    use super::*;
8893
8894    fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
8895        Manifest {
8896            id: id.to_owned(),
8897            name: name.to_owned(),
8898            version: version.to_owned(),
8899            channel: String::new(),
8900            publisher: Some("Acme".to_owned()),
8901            description: Some("desc".to_owned()),
8902            moniker: None,
8903            package_url: None,
8904            publisher_url: None,
8905            publisher_support_url: None,
8906            license: None,
8907            license_url: None,
8908            privacy_url: None,
8909            author: None,
8910            copyright: None,
8911            copyright_url: None,
8912            release_notes: None,
8913            release_notes_url: None,
8914            tags: Vec::new(),
8915            agreements: Vec::new(),
8916            package_dependencies: Vec::new(),
8917            documentation: Vec::new(),
8918            installers: Vec::new(),
8919            require_explicit_upgrade: false,
8920        }
8921    }
8922
8923    #[test]
8924    fn derive_download_filename_matches_winget_pattern() {
8925        let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
8926        let installer = Installer {
8927            architecture: Some("arm64".to_owned()),
8928            installer_type: Some("exe".to_owned()),
8929            scope: Some("user".to_owned()),
8930            locale: Some("en-US".to_owned()),
8931            ..Installer::default()
8932        };
8933        assert_eq!(
8934            derive_download_manifest_filename(&manifest, &installer),
8935            "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
8936        );
8937    }
8938
8939    #[test]
8940    fn derive_download_filename_omits_optional_components() {
8941        let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
8942        let installer = Installer {
8943            architecture: Some("x64".to_owned()),
8944            installer_type: Some("exe".to_owned()),
8945            ..Installer::default()
8946        };
8947        // No scope, no locale → both omitted; architecture remains PascalCase.
8948        assert_eq!(
8949            derive_download_manifest_filename(&manifest, &installer),
8950            "7-Zip_26.01_X64_exe.yaml"
8951        );
8952    }
8953
8954    #[test]
8955    fn derive_download_filename_skips_unknown_version() {
8956        let manifest = sample_manifest("Test.Package", "Unknown", "Test");
8957        let installer = Installer {
8958            architecture: Some("neutral".to_owned()),
8959            installer_type: Some("msi".to_owned()),
8960            ..Installer::default()
8961        };
8962        assert_eq!(
8963            derive_download_manifest_filename(&manifest, &installer),
8964            "Test_Neutral_msi.yaml"
8965        );
8966    }
8967
8968    #[test]
8969    fn derive_download_filename_sanitizes_illegal_chars() {
8970        let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
8971        let installer = Installer {
8972            architecture: Some("x64".to_owned()),
8973            installer_type: Some("exe".to_owned()),
8974            ..Installer::default()
8975        };
8976        // `/` and `:` are illegal in Windows filenames; both must be replaced.
8977        let filename = derive_download_manifest_filename(&manifest, &installer);
8978        assert!(!filename.contains('/') && !filename.contains(':'));
8979        assert!(filename.starts_with("Some_Bad_Name_1.0"));
8980    }
8981
8982    #[test]
8983    fn build_download_manifest_yaml_overrides_identity_fields() {
8984        let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
8985        // Documents contain a stale PackageVersion (e.g. empty) — the typed
8986        // manifest's corrected version must win.
8987        let documents = serde_json::json!({
8988            "PackageIdentifier": "stale-id",
8989            "PackageVersion": "",
8990            "PackageName": "Old Name",
8991            "Channel": "preview",
8992            "Installers": [
8993                { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
8994                { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
8995            ],
8996        });
8997        let installer = Installer {
8998            architecture: Some("x64".to_owned()),
8999            installer_type: Some("exe".to_owned()),
9000            url: Some("https://example.com/a".to_owned()),
9001            ..Installer::default()
9002        };
9003        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9004        assert!(yaml.contains("PackageIdentifier: App.Id"));
9005        assert!(yaml.contains("PackageVersion: 2.0.0"));
9006        assert!(yaml.contains("PackageName: App Name"));
9007        assert!(yaml.contains("ManifestType: merged"));
9008        // Channel had a value in docs but the typed manifest's channel is empty
9009        // → expect it stripped, since winget download manifests don't carry it.
9010        assert!(!yaml.contains("Channel: preview"));
9011        // Installers array narrows to the single selected installer.
9012        assert_eq!(yaml.matches("InstallerUrl").count(), 1);
9013        assert!(yaml.contains("https://example.com/a"));
9014        assert!(!yaml.contains("https://example.com/b"));
9015    }
9016
9017    #[test]
9018    fn build_download_manifest_yaml_preserves_typed_channel() {
9019        let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9020        manifest.channel = "beta".to_owned();
9021        let documents = serde_json::json!({
9022            "PackageIdentifier": "App.Id",
9023            "PackageVersion": "2.0.0",
9024            "Installers": [],
9025        });
9026        let installer = Installer::default();
9027        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9028        assert!(yaml.contains("Channel: beta"));
9029    }
9030
9031    fn temp_app_root(label: &str) -> PathBuf {
9032        static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
9033        std::env::temp_dir().join(format!(
9034            "pinget-rs-tests-{label}-{}-{}",
9035            std::process::id(),
9036            TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
9037        ))
9038    }
9039
9040    #[cfg(windows)]
9041    #[test]
9042    fn path_prefix_check_rejects_string_prefix_collisions() {
9043        // Regression: a naive str::starts_with would falsely consider
9044        // C:\Foobar a child of C:\Foo and let the Links\ sweep delete unrelated
9045        // symlinks. The component-aware check must reject these.
9046        assert!(!path_starts_with_case_insensitive(
9047            Path::new(r"C:\Foobar\bin.exe"),
9048            Path::new(r"C:\Foo"),
9049        ));
9050        assert!(!path_starts_with_case_insensitive(
9051            Path::new(r"C:\Foo\bin.exe.bak"),
9052            Path::new(r"C:\Foo\bin.exe"),
9053        ));
9054    }
9055
9056    #[cfg(windows)]
9057    #[test]
9058    fn path_prefix_check_accepts_real_children_and_self() {
9059        // Dir install: the symlink target lives inside the install dir.
9060        assert!(path_starts_with_case_insensitive(
9061            Path::new(r"C:\Foo\sub\bin.exe"),
9062            Path::new(r"C:\Foo"),
9063        ));
9064        // File install: the symlink points at the same file we removed.
9065        assert!(path_starts_with_case_insensitive(
9066            Path::new(r"C:\Foo\bin.exe"),
9067            Path::new(r"C:\Foo\bin.exe"),
9068        ));
9069    }
9070
9071    #[cfg(windows)]
9072    #[test]
9073    fn path_prefix_check_is_case_insensitive() {
9074        // Windows paths are case-insensitive — winget and pinget may write
9075        // them with different casing.
9076        assert!(path_starts_with_case_insensitive(
9077            Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
9078            Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
9079        ));
9080    }
9081
9082    #[cfg(windows)]
9083    #[test]
9084    fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
9085        let query = PackageQuery {
9086            source: Some("winget".to_owned()),
9087            ..PackageQuery::default()
9088        };
9089        assert_eq!(
9090            portable_source_identifier(&query),
9091            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9092        );
9093        // Case-insensitive — manifests/catalog may report differently.
9094        let query = PackageQuery {
9095            source: Some("WINGET".to_owned()),
9096            ..PackageQuery::default()
9097        };
9098        assert_eq!(
9099            portable_source_identifier(&query),
9100            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9101        );
9102    }
9103
9104    #[cfg(windows)]
9105    #[test]
9106    fn portable_source_identifier_passes_through_custom_sources() {
9107        let query = PackageQuery {
9108            source: Some("internal-feed".to_owned()),
9109            ..PackageQuery::default()
9110        };
9111        assert_eq!(portable_source_identifier(&query), "internal-feed");
9112    }
9113
9114    #[cfg(windows)]
9115    #[test]
9116    fn portable_source_identifier_defaults_for_missing_or_empty_source() {
9117        let query = PackageQuery {
9118            source: None,
9119            ..PackageQuery::default()
9120        };
9121        assert_eq!(
9122            portable_source_identifier(&query),
9123            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9124        );
9125        let query = PackageQuery {
9126            source: Some(String::new()),
9127            ..PackageQuery::default()
9128        };
9129        assert_eq!(
9130            portable_source_identifier(&query),
9131            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9132        );
9133    }
9134
9135    #[cfg(windows)]
9136    #[test]
9137    fn portable_subkey_name_matches_winget_format() {
9138        assert_eq!(
9139            portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
9140            "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
9141        );
9142    }
9143
9144    #[cfg(windows)]
9145    #[test]
9146    fn determine_portable_alias_prefers_nested_installer_alias() {
9147        let installer = Installer {
9148            nested_installer_files: vec![NestedInstallerFile {
9149                relative_file_path: "ripgrep/rg.exe".to_owned(),
9150                portable_command_alias: Some("rg".to_owned()),
9151            }],
9152            commands: vec!["other-name".to_owned()],
9153            ..Installer::default()
9154        };
9155        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
9156    }
9157
9158    #[cfg(windows)]
9159    #[test]
9160    fn determine_portable_alias_falls_back_to_commands() {
9161        let installer = Installer {
9162            // No nested files at all (standalone portable case).
9163            commands: vec!["nuget".to_owned()],
9164            ..Installer::default()
9165        };
9166        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
9167
9168        // Nested files exist but none declare PortableCommandAlias.
9169        let installer = Installer {
9170            nested_installer_files: vec![NestedInstallerFile {
9171                relative_file_path: "subdir/bin.exe".to_owned(),
9172                portable_command_alias: None,
9173            }],
9174            commands: vec!["bin".to_owned()],
9175            ..Installer::default()
9176        };
9177        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
9178    }
9179
9180    #[cfg(windows)]
9181    #[test]
9182    fn determine_portable_alias_returns_none_without_alias_or_commands() {
9183        let installer = Installer::default();
9184        assert!(determine_portable_alias(&installer).is_none());
9185    }
9186
9187    #[cfg(windows)]
9188    #[test]
9189    fn resolve_portable_install_location_prefers_request_override() {
9190        let result = resolve_portable_install_location_pure(
9191            Some(r"D:\Tools\rg"),
9192            Some(r"C:\Software\rg-test"),
9193            "Sub_Source",
9194            Path::new(r"C:\default"),
9195        );
9196        assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
9197    }
9198
9199    #[cfg(windows)]
9200    #[test]
9201    fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
9202        // The #4823 scenario: pinget upgrade with no explicit --location must
9203        // resolve to the install_location the prior install recorded, not the
9204        // default portable root.
9205        let result = resolve_portable_install_location_pure(
9206            None,
9207            Some(r"C:\Software\rg-test"),
9208            "Sub_Source",
9209            Path::new(r"C:\default"),
9210        );
9211        assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
9212    }
9213
9214    #[cfg(windows)]
9215    #[test]
9216    fn resolve_portable_install_location_falls_back_to_default_root() {
9217        let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
9218        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9219    }
9220
9221    #[cfg(windows)]
9222    #[test]
9223    fn resolve_portable_install_location_treats_empty_strings_as_unset() {
9224        // Defensive: PackageQuery / registry reads can yield "" instead of
9225        // None for missing values.
9226        let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
9227        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9228    }
9229
9230    #[cfg(windows)]
9231    #[test]
9232    fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
9233        let root = std::env::temp_dir().join(format!(
9234            "pinget-clean-test-{}",
9235            Utc::now().timestamp_nanos_opt().unwrap_or_default()
9236        ));
9237        fs::create_dir_all(&root).expect("create temp dir");
9238        fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
9239        fs::create_dir_all(root.join("nested")).expect("create nested dir");
9240        fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
9241
9242        clean_directory_contents(&root).expect("clean root contents");
9243
9244        assert!(root.is_dir(), "root dir itself must survive");
9245        assert_eq!(
9246            fs::read_dir(&root).expect("read root").count(),
9247            0,
9248            "root must be empty after clean"
9249        );
9250        let _ = fs::remove_dir_all(&root);
9251    }
9252
9253    #[cfg(windows)]
9254    #[test]
9255    fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
9256        let bogus = std::env::temp_dir().join(format!(
9257            "pinget-clean-test-bogus-{}",
9258            Utc::now().timestamp_nanos_opt().unwrap_or_default()
9259        ));
9260        assert!(!bogus.exists());
9261        clean_directory_contents(&bogus).expect("clean nonexistent dir");
9262        // Should not have created the dir.
9263        assert!(!bogus.exists());
9264    }
9265
9266    #[cfg(windows)]
9267    #[test]
9268    fn path_prefix_check_handles_mixed_separators() {
9269        // NestedInstallerFiles RelativeFilePath uses '/' in manifests, so the
9270        // path we joined can end up mixed-separator on Windows.
9271        assert!(path_starts_with_case_insensitive(
9272            Path::new(r"C:\Foo\sub/bin.exe"),
9273            Path::new(r"C:\Foo"),
9274        ));
9275    }
9276
9277    #[test]
9278    fn repository_options_capture_custom_host_settings() {
9279        let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
9280            .with_user_agent("pinget-rs-tests/1.0")
9281            .with_pre_indexed_source_auto_update_interval(None);
9282
9283        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
9284        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
9285        assert!(options.pre_indexed_source_auto_update_interval.is_none());
9286    }
9287
9288    #[test]
9289    fn dependency_install_requests_are_silent_and_not_forced() {
9290        let mut parent = InstallRequest::new(PackageQuery {
9291            source: Some("winget".to_owned()),
9292            ..PackageQuery::default()
9293        });
9294        parent.mode = InstallerMode::SilentWithProgress;
9295        parent.force = true;
9296        parent.accept_package_agreements = true;
9297        parent.ignore_security_hash = true;
9298        parent.dependency_source = Some("dependencies".to_owned());
9299
9300        let dependency =
9301            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
9302
9303        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
9304        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
9305        assert_eq!(dependency.mode, InstallerMode::Silent);
9306        assert!(!dependency.force);
9307        assert!(dependency.no_upgrade);
9308        assert!(dependency.accept_package_agreements);
9309        assert!(dependency.ignore_security_hash);
9310    }
9311
9312    #[test]
9313    fn storage_paths_use_configured_app_root() {
9314        let app_root = PathBuf::from(r"C:\temp\pinget-test");
9315        let source = SourceRecord {
9316            name: "winget/test".to_owned(),
9317            kind: SourceKind::PreIndexed,
9318            arg: "https://example.com/cache".to_owned(),
9319            identifier: "Test.Source".to_owned(),
9320            trust_level: "None".to_owned(),
9321            explicit: false,
9322            priority: 0,
9323            last_update: None,
9324            source_version: None,
9325        };
9326
9327        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
9328        assert_eq!(
9329            source_state_dir(&app_root, &source),
9330            app_root.join("sources").join("winget_test")
9331        );
9332        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
9333    }
9334
9335    #[cfg(windows)]
9336    #[test]
9337    fn packaged_layout_paths_resolve_from_packaged_app_root() {
9338        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
9339        let app_root = local_app_data
9340            .join("Packages")
9341            .join(PACKAGED_FAMILY_NAME)
9342            .join("LocalState");
9343        assert!(uses_packaged_layout(&app_root));
9344        assert!(
9345            user_settings_path(&app_root)
9346                .display()
9347                .to_string()
9348                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
9349        );
9350        assert!(
9351            packaged_file_cache_root(&app_root)
9352                .display()
9353                .to_string()
9354                .ends_with(&format!(
9355                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
9356                    PACKAGED_FAMILY_NAME
9357                ))
9358        );
9359    }
9360
9361    #[cfg(windows)]
9362    #[test]
9363    fn default_app_root_outside_package_avoids_packaged_layout() {
9364        // Tests run without AppX package identity, so the default app root must not
9365        // resolve to the WinGet packaged LocalState — that location requires a
9366        // brokered/elevated writer for its secure-settings stream.
9367        let prior = std::env::var_os("PINGET_APPROOT");
9368        // SAFETY: tests in this module are not run concurrently with other env mutators.
9369        unsafe { std::env::remove_var("PINGET_APPROOT") };
9370
9371        let result = default_app_root();
9372
9373        if let Some(prior) = prior {
9374            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
9375            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
9376        }
9377
9378        let app_root = result.expect("default app root");
9379        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
9380        assert!(
9381            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
9382            "unexpected fallback app root: {app_root:?}"
9383        );
9384    }
9385
9386    #[test]
9387    fn packaged_source_stream_overlays_defaults_and_metadata() {
9388        let store = parse_packaged_source_store(
9389            Some(
9390                "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",
9391            ),
9392            Some(
9393                "Sources:\n  - Name: corp\n    LastUpdate: 1700000000\n    SourceVersion: 1.2.3\n",
9394            ),
9395        )
9396        .expect("packaged store");
9397
9398        assert!(!store.sources.iter().any(|source| source.name == "winget"));
9399        let corp = store
9400            .sources
9401            .iter()
9402            .find(|source| source.name == "corp")
9403            .expect("corp source");
9404        assert_eq!(corp.kind, SourceKind::Rest);
9405        assert_eq!(corp.identifier, "Contoso.Rest");
9406        assert_eq!(corp.trust_level, "Trusted");
9407        assert!(corp.explicit);
9408        assert_eq!(corp.priority, 7);
9409        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
9410        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
9411    }
9412
9413    #[test]
9414    fn system_winget_export_parses_json_lines() {
9415        let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9416{"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"}
9417"#;
9418
9419        let sources = parse_system_winget_source_export(output).expect("source export");
9420
9421        assert_eq!(
9422            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
9423            vec!["contoso", "contoso-cache"]
9424        );
9425        assert_eq!(sources[0].kind, SourceKind::Rest);
9426        assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
9427        assert_eq!(sources[0].identifier, "api.contoso.test");
9428        assert_eq!(sources[0].trust_level, "Trusted");
9429        assert!(!sources[0].explicit);
9430        assert_eq!(sources[1].kind, SourceKind::PreIndexed);
9431        assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
9432        assert!(sources[1].explicit);
9433    }
9434
9435    #[test]
9436    fn system_winget_export_parses_wrapped_sources() {
9437        let output = r#"{
9438  "Sources": [
9439    {
9440      "Name": "corp",
9441      "Type": "Microsoft.Rest",
9442      "Arg": "https://packages.contoso.test/api",
9443      "Identifier": "Contoso.Rest",
9444      "TrustLevel": "Trusted",
9445      "Explicit": true,
9446      "Priority": 9
9447    }
9448  ]
9449}"#;
9450
9451        let sources = parse_system_winget_source_export(output).expect("source export");
9452        let source = sources.first().expect("source");
9453        assert_eq!(sources.len(), 1);
9454        assert_eq!(source.name, "corp");
9455        assert_eq!(source.kind, SourceKind::Rest);
9456        assert_eq!(source.identifier, "Contoso.Rest");
9457        assert_eq!(source.trust_level, "Trusted");
9458        assert!(source.explicit);
9459        assert_eq!(source.priority, 9);
9460    }
9461
9462    #[test]
9463    fn system_winget_source_store_detects_secure_settings_stub() {
9464        assert!(is_secure_settings_stub(Some(
9465            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
9466        )));
9467        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
9468    }
9469
9470    #[test]
9471    fn system_winget_source_store_builds_add_arguments() {
9472        let args = build_winget_source_add_arguments(
9473            "corp",
9474            "https://packages.contoso.test/api",
9475            SourceKind::Rest,
9476            "Trusted",
9477            true,
9478        );
9479
9480        assert_eq!(
9481            args,
9482            vec![
9483                "source",
9484                "add",
9485                "--name",
9486                "corp",
9487                "--arg",
9488                "https://packages.contoso.test/api",
9489                "--type",
9490                "Microsoft.Rest",
9491                "--disable-interactivity",
9492                "--trust-level",
9493                "trusted",
9494                "--explicit",
9495            ]
9496        );
9497    }
9498
9499    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
9500        Ok(WingetSourceCommandResult {
9501            exit_code: 0,
9502            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9503"#
9504            .to_owned(),
9505            stderr: String::new(),
9506        })
9507    }
9508
9509    #[test]
9510    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
9511        let original_runner = {
9512            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9513            let original = *runner;
9514            *runner = fake_system_winget_source_export;
9515            original
9516        };
9517
9518        let result = load_packaged_store_from_streams(
9519            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
9520            None,
9521        );
9522
9523        {
9524            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9525            *runner = original_runner;
9526        }
9527
9528        let store = result.expect("packaged store").expect("packaged store");
9529        let source = store.sources.first().expect("source");
9530        assert_eq!(store.sources.len(), 1);
9531        assert_eq!(source.name, "contoso");
9532        assert_eq!(source.kind, SourceKind::Rest);
9533    }
9534
9535    #[test]
9536    fn source_metadata_and_named_reset_round_trip() {
9537        let app_root = temp_app_root("source-reset");
9538        let result = (|| -> Result<()> {
9539            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9540            repository.add_source_with_metadata(
9541                "test",
9542                "https://example.com/test",
9543                SourceKind::Rest,
9544                Some("trusted"),
9545                true,
9546                4,
9547            )?;
9548
9549            repository.edit_source("test", Some(false), None)?;
9550            let edited = repository
9551                .list_sources()
9552                .into_iter()
9553                .find(|source| source.name == "test")
9554                .expect("source");
9555            assert_eq!(edited.trust_level, "Trusted");
9556            assert!(!edited.explicit);
9557            assert_eq!(edited.priority, 4);
9558
9559            repository.reset_source("test")?;
9560            let reset = repository
9561                .list_sources()
9562                .into_iter()
9563                .find(|source| source.name == "test")
9564                .expect("source");
9565            assert_eq!(reset.trust_level, "Trusted");
9566            assert!(!reset.explicit);
9567            assert_eq!(reset.priority, 4);
9568            assert!(reset.last_update.is_none());
9569            assert!(reset.source_version.is_none());
9570            Ok(())
9571        })();
9572
9573        let _ = fs::remove_dir_all(&app_root);
9574        result.expect("source metadata");
9575    }
9576
9577    #[test]
9578    fn explicit_sources_are_skipped_by_default_search_resolution() {
9579        let app_root = temp_app_root("source-resolution");
9580        let result = (|| -> Result<()> {
9581            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9582            repository.add_source_with_metadata(
9583                "explicit-test",
9584                "https://example.com/explicit",
9585                SourceKind::Rest,
9586                Some("trusted"),
9587                true,
9588                10,
9589            )?;
9590
9591            let indexes = repository.resolve_source_indexes(None)?;
9592            let names: Vec<_> = indexes
9593                .into_iter()
9594                .map(|index| repository.list_sources()[index].name.clone())
9595                .collect();
9596            assert!(!names.iter().any(|name| name == "explicit-test"));
9597            Ok(())
9598        })();
9599
9600        let _ = fs::remove_dir_all(&app_root);
9601        result.expect("source resolution");
9602    }
9603
9604    #[test]
9605    fn user_and_admin_settings_round_trip() {
9606        let app_root = temp_app_root("settings");
9607        let result = (|| -> Result<()> {
9608            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9609            repository.set_user_settings(
9610                &serde_json::json!({
9611                    "visual": {
9612                        "progressBar": "retro"
9613                    }
9614                }),
9615                false,
9616            )?;
9617            repository.set_user_settings(
9618                &serde_json::json!({
9619                    "experimentalFeatures": {
9620                        "directMSI": true
9621                    }
9622                }),
9623                true,
9624            )?;
9625
9626            let user_settings = repository.get_user_settings()?;
9627            assert_eq!(
9628                user_settings["visual"]["progressBar"],
9629                serde_json::Value::String("retro".to_owned())
9630            );
9631            assert_eq!(
9632                user_settings["experimentalFeatures"]["directMSI"],
9633                serde_json::Value::Bool(true)
9634            );
9635            assert!(repository.test_user_settings(
9636                &serde_json::json!({
9637                    "experimentalFeatures": {
9638                        "directMSI": true
9639                    }
9640                }),
9641                true
9642            )?);
9643
9644            repository.set_admin_setting("LocalManifestFiles", true)?;
9645            repository.set_admin_setting("InstallerHashOverride", true)?;
9646            let admin_settings = repository.get_admin_settings()?;
9647            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
9648            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
9649
9650            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
9651            let reset_one = repository.get_admin_settings()?;
9652            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
9653
9654            repository.reset_admin_setting(None, true)?;
9655            let reset_all = repository.get_admin_settings()?;
9656            for name in Repository::supported_admin_settings() {
9657                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
9658            }
9659            Ok(())
9660        })();
9661
9662        let _ = fs::remove_dir_all(&app_root);
9663        result.expect("settings");
9664    }
9665
9666    #[test]
9667    fn show_result_structured_document_is_manifest_oriented() {
9668        let result = ShowResult {
9669            package: SearchMatch {
9670                source_name: "winget".to_owned(),
9671                source_kind: SourceKind::PreIndexed,
9672                id: "Test.Package".to_owned(),
9673                name: "Test Package".to_owned(),
9674                moniker: Some("testpkg".to_owned()),
9675                version: Some("1.2.3".to_owned()),
9676                channel: Some("stable".to_owned()),
9677                match_criteria: Some("Id".to_owned()),
9678            },
9679            manifest: Manifest {
9680                id: "Test.Package".to_owned(),
9681                name: "Test Package".to_owned(),
9682                version: "1.2.3".to_owned(),
9683                channel: "stable".to_owned(),
9684                publisher: Some("Contoso".to_owned()),
9685                description: Some("Structured output".to_owned()),
9686                moniker: Some("testpkg".to_owned()),
9687                package_url: None,
9688                publisher_url: None,
9689                publisher_support_url: None,
9690                license: None,
9691                license_url: None,
9692                privacy_url: None,
9693                author: None,
9694                copyright: None,
9695                copyright_url: None,
9696                release_notes: None,
9697                release_notes_url: None,
9698                tags: vec!["utility".to_owned()],
9699                agreements: Vec::new(),
9700                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
9701                documentation: vec![Documentation {
9702                    label: Some("Docs".to_owned()),
9703                    url: "https://example.test/docs".to_owned(),
9704                }],
9705                installers: vec![Installer {
9706                    architecture: Some("x64".to_owned()),
9707                    installer_type: Some("msix".to_owned()),
9708                    url: Some("https://example.test/Test.Package.msix".to_owned()),
9709                    sha256: Some("ABC123".to_owned()),
9710                    product_code: None,
9711                    locale: Some("en-US".to_owned()),
9712                    scope: Some("machine".to_owned()),
9713                    release_date: None,
9714                    package_family_name: None,
9715                    upgrade_code: None,
9716                    platforms: Vec::new(),
9717                    minimum_os_version: None,
9718                    switches: InstallerSwitches {
9719                        silent: Some("/quiet".to_owned()),
9720                        ..InstallerSwitches::default()
9721                    },
9722                    commands: vec!["testpkg".to_owned()],
9723                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9724                    require_explicit_upgrade: false,
9725                    nested_installer_type: None,
9726                    nested_installer_files: Vec::new(),
9727                }],
9728                require_explicit_upgrade: false,
9729            },
9730            selected_installer: Some(Installer {
9731                architecture: Some("x64".to_owned()),
9732                installer_type: Some("msix".to_owned()),
9733                url: Some("https://example.test/Test.Package.msix".to_owned()),
9734                sha256: Some("ABC123".to_owned()),
9735                product_code: None,
9736                locale: Some("en-US".to_owned()),
9737                scope: Some("machine".to_owned()),
9738                release_date: None,
9739                package_family_name: None,
9740                upgrade_code: None,
9741                platforms: Vec::new(),
9742                minimum_os_version: None,
9743                switches: InstallerSwitches {
9744                    silent: Some("/quiet".to_owned()),
9745                    ..InstallerSwitches::default()
9746                },
9747                commands: vec!["testpkg".to_owned()],
9748                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9749                require_explicit_upgrade: false,
9750                nested_installer_type: None,
9751                nested_installer_files: Vec::new(),
9752            }),
9753            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
9754            warnings: vec!["cache warmed".to_owned()],
9755            manifest_documents: JsonValue::Array(vec![
9756                serde_json::json!({
9757                    "PackageIdentifier": "Test.Package",
9758                    "PackageVersion": "1.2.3",
9759                    "DefaultLocale": "en-US",
9760                    "ManifestType": "version",
9761                    "ManifestVersion": "1.10.0"
9762                }),
9763                serde_json::json!({
9764                    "PackageIdentifier": "Test.Package",
9765                    "PackageVersion": "1.2.3",
9766                    "PackageLocale": "en-US",
9767                    "PackageName": "Test Package",
9768                    "Publisher": "Example",
9769                    "License": "MIT",
9770                    "ShortDescription": "Structured output",
9771                    "ManifestType": "defaultLocale",
9772                    "ManifestVersion": "1.10.0"
9773                }),
9774                serde_json::json!({
9775                    "PackageIdentifier": "Test.Package",
9776                    "PackageVersion": "1.2.3",
9777                    "ManifestType": "installer",
9778                    "ManifestVersion": "1.10.0",
9779                    "Installers": [
9780                        {
9781                            "Architecture": "x64",
9782                            "InstallerType": "msix",
9783                            "InstallerUrl": "https://example.test/Test.Package.msix",
9784                            "InstallerSha256": "ABC123",
9785                            "Commands": ["testpkg"],
9786                            "InstallerSwitches": { "Silent": "/quiet" },
9787                            "Dependencies": {
9788                                "PackageDependencies": [
9789                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
9790                                ]
9791                            }
9792                        }
9793                    ]
9794                }),
9795            ]),
9796        };
9797
9798        let document = result.structured_document();
9799
9800        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
9801        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
9802        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
9803        assert_eq!(
9804            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
9805            Some("Microsoft.VCRedist.2015+.x64")
9806        );
9807        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
9808        assert_eq!(
9809            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
9810            Some("/quiet")
9811        );
9812    }
9813
9814    #[test]
9815    fn manifest_parses_require_explicit_upgrade_at_top_level() {
9816        // Top-level RequireExplicitUpgrade flag should propagate to
9817        // Manifest.require_explicit_upgrade. winget catalogs typically
9818        // place the flag here for browser packages and similar
9819        // self-updating apps that opt out of bulk `upgrade`.
9820        let yaml = r#"
9821PackageIdentifier: Test.Package
9822PackageVersion: 1.2.3
9823DefaultLocale: en-US
9824ManifestType: singleton
9825ManifestVersion: 1.10.0
9826PackageLocale: en-US
9827PackageName: Test Package
9828Publisher: Example
9829License: MIT
9830ShortDescription: explicit-upgrade fixture
9831RequireExplicitUpgrade: true
9832Installers:
9833  - Architecture: x64
9834    InstallerType: exe
9835    InstallerUrl: https://example.test/Test.Package.exe
9836    InstallerSha256: ABC123
9837"#;
9838        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9839        assert!(manifest.require_explicit_upgrade);
9840    }
9841
9842    #[test]
9843    fn manifest_parses_require_explicit_upgrade_on_installer() {
9844        // Per-installer flag — only one of several installers declares it,
9845        // but the Manifest aggregate must still be `true` because the user
9846        // could pick that installer when upgrading.
9847        let yaml = r#"
9848PackageIdentifier: Test.Package
9849PackageVersion: 1.2.3
9850DefaultLocale: en-US
9851ManifestType: singleton
9852ManifestVersion: 1.10.0
9853PackageLocale: en-US
9854PackageName: Test Package
9855Publisher: Example
9856License: MIT
9857ShortDescription: explicit-upgrade fixture
9858Installers:
9859  - Architecture: x64
9860    InstallerType: exe
9861    InstallerUrl: https://example.test/Test.Package.x64.exe
9862    InstallerSha256: ABC123
9863  - Architecture: arm64
9864    InstallerType: exe
9865    InstallerUrl: https://example.test/Test.Package.arm64.exe
9866    InstallerSha256: DEF456
9867    RequireExplicitUpgrade: true
9868"#;
9869        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9870        assert!(manifest.require_explicit_upgrade);
9871        assert!(!manifest.installers[0].require_explicit_upgrade);
9872        assert!(manifest.installers[1].require_explicit_upgrade);
9873    }
9874
9875    #[test]
9876    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
9877        let yaml = r#"
9878PackageIdentifier: Test.Package
9879PackageVersion: 1.2.3
9880DefaultLocale: en-US
9881ManifestType: singleton
9882ManifestVersion: 1.10.0
9883PackageLocale: en-US
9884PackageName: Test Package
9885Publisher: Example
9886License: MIT
9887ShortDescription: baseline fixture
9888Installers:
9889  - Architecture: x64
9890    InstallerType: exe
9891    InstallerUrl: https://example.test/Test.Package.exe
9892    InstallerSha256: ABC123
9893"#;
9894        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9895        assert!(!manifest.require_explicit_upgrade);
9896    }
9897
9898    #[test]
9899    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
9900        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
9901        // (Edge, Steam, Discord). pinget must do the same — the filter is
9902        // the only gate that enforces it.
9903        let mut pkg = InstalledPackage {
9904            name: "Edge".to_owned(),
9905            local_id: r"ARP\Machine\X64\Edge".to_owned(),
9906            installed_version: "100.0".to_owned(),
9907            publisher: None,
9908            scope: Some("Machine".to_owned()),
9909            installer_category: Some("exe".to_owned()),
9910            install_location: None,
9911            package_family_names: Vec::new(),
9912            product_codes: Vec::new(),
9913            upgrade_codes: Vec::new(),
9914            correlated: Some(SearchMatch {
9915                source_name: "winget".to_owned(),
9916                source_kind: SourceKind::PreIndexed,
9917                id: "Microsoft.Edge".to_owned(),
9918                name: "Microsoft Edge".to_owned(),
9919                moniker: None,
9920                version: Some("110.0".to_owned()),
9921                channel: None,
9922                match_criteria: None,
9923            }),
9924            installed_version_canonical: false,
9925            correlated_requires_explicit_upgrade: true,
9926            correlated_lacks_compatible_installer: false,
9927        };
9928        let bulk_query = ListQuery {
9929            upgrade_only: true,
9930            ..ListQuery::default()
9931        };
9932        assert!(
9933            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
9934            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
9935        );
9936
9937        // When the user explicitly targets it by id, winget shows it —
9938        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
9939        let filtered_query = ListQuery {
9940            upgrade_only: true,
9941            id: Some("Microsoft.Edge".to_owned()),
9942            ..ListQuery::default()
9943        };
9944        assert!(
9945            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
9946            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
9947        );
9948
9949        // Without the flag, the row appears in bulk upgrade as usual.
9950        pkg.correlated_requires_explicit_upgrade = false;
9951        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9952    }
9953
9954    #[test]
9955    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
9956        let mut pkg = InstalledPackage {
9957            name: "Foo".to_owned(),
9958            local_id: r"ARP\Machine\X64\Foo".to_owned(),
9959            installed_version: "1.0".to_owned(),
9960            publisher: None,
9961            scope: Some("Machine".to_owned()),
9962            installer_category: Some("exe".to_owned()),
9963            install_location: None,
9964            package_family_names: Vec::new(),
9965            product_codes: Vec::new(),
9966            upgrade_codes: Vec::new(),
9967            correlated: Some(SearchMatch {
9968                source_name: "winget".to_owned(),
9969                source_kind: SourceKind::PreIndexed,
9970                id: "Test.Foo".to_owned(),
9971                name: "Foo".to_owned(),
9972                moniker: None,
9973                version: Some("2.0".to_owned()),
9974                channel: None,
9975                match_criteria: None,
9976            }),
9977            installed_version_canonical: false,
9978            correlated_requires_explicit_upgrade: false,
9979            correlated_lacks_compatible_installer: true,
9980        };
9981        let bulk_query = ListQuery {
9982            upgrade_only: true,
9983            ..ListQuery::default()
9984        };
9985        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9986
9987        // Explicit `--id` still surfaces it.
9988        let filtered_query = ListQuery {
9989            upgrade_only: true,
9990            id: Some("Test.Foo".to_owned()),
9991            ..ListQuery::default()
9992        };
9993        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
9994
9995        // Cleared flag → visible in bulk.
9996        pkg.correlated_lacks_compatible_installer = false;
9997        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9998    }
9999
10000    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
10001        let installers = arches
10002            .iter()
10003            .map(|a| Installer {
10004                architecture: Some((*a).to_owned()),
10005                installer_type: None,
10006                url: None,
10007                sha256: None,
10008                product_code: None,
10009                locale: None,
10010                scope: None,
10011                release_date: None,
10012                package_family_name: None,
10013                upgrade_code: None,
10014                platforms: Vec::new(),
10015                minimum_os_version: None,
10016                switches: InstallerSwitches::default(),
10017                commands: Vec::new(),
10018                package_dependencies: Vec::new(),
10019                require_explicit_upgrade: false,
10020                nested_installer_type: None,
10021                nested_installer_files: Vec::new(),
10022            })
10023            .collect();
10024        Manifest {
10025            id: "Test".to_owned(),
10026            name: "Test".to_owned(),
10027            version: "1.0".to_owned(),
10028            channel: String::new(),
10029            publisher: None,
10030            description: None,
10031            moniker: None,
10032            package_url: None,
10033            publisher_url: None,
10034            publisher_support_url: None,
10035            license: None,
10036            license_url: None,
10037            privacy_url: None,
10038            author: None,
10039            copyright: None,
10040            copyright_url: None,
10041            release_notes: None,
10042            release_notes_url: None,
10043            tags: Vec::new(),
10044            agreements: Vec::new(),
10045            package_dependencies: Vec::new(),
10046            documentation: Vec::new(),
10047            installers,
10048            require_explicit_upgrade: false,
10049        }
10050    }
10051
10052    #[test]
10053    fn manifest_compatible_installer_neutral_always_matches() {
10054        // `neutral` is in every host's allowed list.
10055        assert!(manifest_has_compatible_installer(
10056            &synthetic_manifest_with_installer_arches(&["neutral"])
10057        ));
10058    }
10059
10060    #[test]
10061    fn manifest_compatible_installer_empty_installers_pass_through() {
10062        // Manifest with no installers (e.g. a docs-only entry): nothing to
10063        // filter against, don't hide the row.
10064        assert!(manifest_has_compatible_installer(
10065            &synthetic_manifest_with_installer_arches(&[])
10066        ));
10067    }
10068
10069    #[test]
10070    fn manifest_compatible_installer_rejects_alien_arch_only() {
10071        // `ppc` is in no host's allowed list — manifest has nothing runnable.
10072        assert!(!manifest_has_compatible_installer(
10073            &synthetic_manifest_with_installer_arches(&["ppc"])
10074        ));
10075    }
10076
10077    #[test]
10078    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
10079        // Mixed list where at least `neutral` is in every host's preference.
10080        assert!(manifest_has_compatible_installer(
10081            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
10082        ));
10083    }
10084
10085    #[test]
10086    fn lookup_unique_normalized_identity_returns_unique_match() {
10087        // Single (norm_name, norm_publisher) intersection — happy path.
10088        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10089        execute_batch_sql(
10090            &connection,
10091            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10092             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10093             INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
10094             INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
10095        )
10096        .expect("seed schema");
10097
10098        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
10099            .expect("query")
10100            .expect("unique match");
10101        assert_eq!(rowid, 100);
10102    }
10103
10104    #[test]
10105    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
10106        // Two distinct packages share the same (norm_name, norm_publisher)
10107        // — the Git case where both `Git.Git` and a hypothetical sibling
10108        // normalize identically. winget refuses to correlate when it can't
10109        // disambiguate; pinget must do the same.
10110        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10111        execute_batch_sql(
10112            &connection,
10113            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10114             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10115             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10116             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
10117        )
10118        .expect("seed schema");
10119
10120        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10121        assert!(rowid.is_none(), "ambiguous match must not correlate");
10122    }
10123
10124    #[test]
10125    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
10126        // norm_name has multiple matches but only one shares its
10127        // norm_publisher with the installed package. Winget's intersect
10128        // logic still picks the right one — verify.
10129        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10130        execute_batch_sql(
10131            &connection,
10132            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10133             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10134             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10135             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
10136        )
10137        .expect("seed schema");
10138
10139        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10140        assert_eq!(rowid, Some(100));
10141    }
10142
10143    #[test]
10144    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
10145        // Name matches but no publisher row for that package id —
10146        // intersection is empty so we don't correlate.
10147        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10148        execute_batch_sql(
10149            &connection,
10150            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10151             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10152             INSERT INTO norm_names2 VALUES ('foo', 100);\n\
10153             INSERT INTO norm_publishers2 VALUES ('bar', 200);",
10154        )
10155        .expect("seed schema");
10156
10157        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
10158        assert!(
10159            rowid.is_none(),
10160            "name matches package 100 but publisher matches 200 — no intersection"
10161        );
10162    }
10163
10164    #[test]
10165    fn parse_yaml_manifest_bundle_returns_singleton_document() {
10166        let yaml = r#"
10167PackageIdentifier: Test.Package
10168PackageVersion: 1.2.3
10169DefaultLocale: en-US
10170ManifestType: version
10171ManifestVersion: 1.10.0
10172---
10173PackageIdentifier: Test.Package
10174PackageVersion: 1.2.3
10175PackageLocale: en-US
10176PackageName: Test Package
10177Publisher: Example
10178License: MIT
10179ShortDescription: Structured output
10180ManifestType: defaultLocale
10181ManifestVersion: 1.10.0
10182---
10183PackageIdentifier: Test.Package
10184PackageVersion: 1.2.3
10185ManifestType: installer
10186ManifestVersion: 1.10.0
10187Installers:
10188  - Architecture: x64
10189    InstallerType: exe
10190    InstallerUrl: https://example.test/Test.Package.exe
10191    InstallerSha256: ABC123
10192"#;
10193
10194        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
10195
10196        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
10197        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
10198        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
10199    }
10200
10201    #[test]
10202    fn collapse_structured_documents_returns_plural_show_documents() {
10203        let documents = collapse_structured_documents(&[
10204            JsonValue::Array(vec![
10205                serde_json::json!({
10206                    "PackageIdentifier": "Test.Package.One",
10207                    "PackageVersion": "1.0.0",
10208                    "DefaultLocale": "en-US",
10209                    "ManifestType": "version",
10210                    "ManifestVersion": "1.10.0"
10211                }),
10212                serde_json::json!({
10213                    "PackageIdentifier": "Test.Package.One",
10214                    "PackageVersion": "1.0.0",
10215                    "PackageLocale": "en-US",
10216                    "PackageName": "Test Package One",
10217                    "ManifestType": "defaultLocale",
10218                    "ManifestVersion": "1.10.0"
10219                }),
10220                serde_json::json!({
10221                    "PackageIdentifier": "Test.Package.One",
10222                    "PackageVersion": "1.0.0",
10223                    "ManifestType": "installer",
10224                    "ManifestVersion": "1.10.0",
10225                    "Installers": [
10226                        {
10227                            "Architecture": "x64",
10228                            "InstallerType": "exe",
10229                            "InstallerUrl": "https://example.test/one.exe",
10230                            "InstallerSha256": "ABC123"
10231                        }
10232                    ]
10233                }),
10234            ]),
10235            serde_json::json!({
10236                "PackageIdentifier": "Test.Package.Two",
10237                "PackageVersion": "2.0.0",
10238                "PackageLocale": "en-US",
10239                "PackageName": "Test Package Two",
10240                "ManifestType": "singleton",
10241                "ManifestVersion": "1.12.0"
10242            }),
10243        ]);
10244
10245        assert_eq!(documents.len(), 2);
10246        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
10247        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
10248        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
10249        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
10250        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
10251    }
10252
10253    #[test]
10254    fn unsupported_action_result_marks_noop_and_warning() {
10255        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
10256
10257        assert!(result.success);
10258        assert!(result.no_op);
10259        assert_eq!(result.exit_code, 0);
10260        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
10261    }
10262
10263    #[test]
10264    fn create_install_no_op_result_honors_no_upgrade() {
10265        let mut request = InstallRequest::new(PackageQuery {
10266            id: Some("Contoso.App".to_owned()),
10267            ..PackageQuery::default()
10268        });
10269        request.no_upgrade = true;
10270
10271        let manifest = Manifest {
10272            id: "Contoso.App".to_owned(),
10273            name: "Contoso App".to_owned(),
10274            version: "2.0.0".to_owned(),
10275            channel: String::new(),
10276            publisher: None,
10277            description: None,
10278            moniker: None,
10279            package_url: None,
10280            publisher_url: None,
10281            publisher_support_url: None,
10282            license: None,
10283            license_url: None,
10284            privacy_url: None,
10285            author: None,
10286            copyright: None,
10287            copyright_url: None,
10288            release_notes: None,
10289            release_notes_url: None,
10290            tags: Vec::new(),
10291            agreements: Vec::new(),
10292            package_dependencies: Vec::new(),
10293            documentation: Vec::new(),
10294            installers: Vec::new(),
10295            require_explicit_upgrade: false,
10296        };
10297        let existing = ListMatch {
10298            name: "Contoso App".to_owned(),
10299            id: "Contoso.App".to_owned(),
10300            local_id: "Contoso.App".to_owned(),
10301            installed_version: "1.0.0".to_owned(),
10302            available_version: Some("2.0.0".to_owned()),
10303            source_name: Some("winget".to_owned()),
10304            publisher: None,
10305            scope: None,
10306            installer_category: None,
10307            install_location: None,
10308            package_family_names: Vec::new(),
10309            product_codes: Vec::new(),
10310            upgrade_codes: Vec::new(),
10311        };
10312
10313        let result =
10314            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10315
10316        assert!(result.success);
10317        assert!(result.no_op);
10318        assert_eq!(result.version, "1.0.0");
10319        assert_eq!(
10320            result.warnings,
10321            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
10322        );
10323    }
10324
10325    #[test]
10326    fn create_install_no_op_result_skips_reinstall_when_current() {
10327        let request = InstallRequest::new(PackageQuery {
10328            id: Some("Contoso.App".to_owned()),
10329            ..PackageQuery::default()
10330        });
10331
10332        let manifest = Manifest {
10333            id: "Contoso.App".to_owned(),
10334            name: "Contoso App".to_owned(),
10335            version: "2.0.0".to_owned(),
10336            channel: String::new(),
10337            publisher: None,
10338            description: None,
10339            moniker: None,
10340            package_url: None,
10341            publisher_url: None,
10342            publisher_support_url: None,
10343            license: None,
10344            license_url: None,
10345            privacy_url: None,
10346            author: None,
10347            copyright: None,
10348            copyright_url: None,
10349            release_notes: None,
10350            release_notes_url: None,
10351            tags: Vec::new(),
10352            agreements: Vec::new(),
10353            package_dependencies: Vec::new(),
10354            documentation: Vec::new(),
10355            installers: Vec::new(),
10356            require_explicit_upgrade: false,
10357        };
10358        let existing = ListMatch {
10359            name: "Contoso App".to_owned(),
10360            id: "Contoso.App".to_owned(),
10361            local_id: "Contoso.App".to_owned(),
10362            installed_version: "2.0.0".to_owned(),
10363            available_version: Some("2.0.0".to_owned()),
10364            source_name: Some("winget".to_owned()),
10365            publisher: None,
10366            scope: None,
10367            installer_category: None,
10368            install_location: None,
10369            package_family_names: Vec::new(),
10370            product_codes: Vec::new(),
10371            upgrade_codes: Vec::new(),
10372        };
10373
10374        let result =
10375            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10376
10377        assert!(result.success);
10378        assert!(result.no_op);
10379        assert_eq!(result.version, "2.0.0");
10380        assert_eq!(
10381            result.warnings,
10382            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
10383        );
10384    }
10385
10386    #[test]
10387    fn collapse_structured_document_projects_merged_manifest() {
10388        let document = collapse_structured_document(&serde_json::json!({
10389            "PackageIdentifier": "Test.Package",
10390            "PackageVersion": "1.2.3",
10391            "PackageLocale": "en-US",
10392            "PackageName": "Test Package",
10393            "Publisher": "Example",
10394            "InstallerType": "exe",
10395            "Installers": [
10396                {
10397                    "Architecture": "x64",
10398                    "InstallerUrl": "https://example.test/Test.Package.exe",
10399                    "InstallerSha256": "ABC123"
10400                }
10401            ],
10402            "ManifestType": "merged",
10403            "ManifestVersion": "1.10.0"
10404        }));
10405
10406        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
10407        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
10408        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
10409        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
10410        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
10411    }
10412
10413    #[test]
10414    fn parses_fixture_manifest() {
10415        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
10416            .join("tests")
10417            .join("fixtures")
10418            .join("ManifestV1_28-Singleton.yaml");
10419        let bytes = fs::read(root).expect("fixture bytes");
10420        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
10421        assert_eq!(manifest.id, "microsoft.msixsdk");
10422        assert_eq!(manifest.version, "1.7.32");
10423        assert_eq!(manifest.name, "MSIX SDK");
10424        assert!(!manifest.installers.is_empty());
10425    }
10426
10427    #[test]
10428    fn parses_installer_switches_from_yaml_manifest() {
10429        let yaml = r#"
10430PackageIdentifier: Test.Package
10431PackageVersion: 1.2.3
10432PackageName: Test Package
10433InstallerSwitches:
10434  SilentWithProgress: /SILENT
10435Installers:
10436  - Architecture: x64
10437    InstallerType: inno
10438    InstallerUrl: https://example.test/Test.Package.exe
10439    InstallerSha256: ABC123
10440    InstallerSwitches:
10441      Silent: /VERYSILENT
10442      Interactive: /HELP
10443"#;
10444
10445        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10446        let installer = &manifest.installers[0];
10447
10448        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
10449        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
10450        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
10451    }
10452
10453    #[test]
10454    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
10455        let yaml = r#"
10456PackageIdentifier: Test.Package
10457PackageVersion: 1.2.3
10458PackageName: Test Package
10459Platform:
10460  - Windows.Desktop
10461MinimumOSVersion: 10.0.19041.0
10462Installers:
10463  - Architecture: x64
10464    InstallerType: exe
10465    InstallerUrl: https://example.test/Test.Package.exe
10466    InstallerSha256: ABC123
10467  - Architecture: x64
10468    InstallerType: msix
10469    Platform:
10470      - Windows.Universal
10471    MinimumOSVersion: 10.0.22621.0
10472    InstallerUrl: https://example.test/Test.Package.msix
10473    InstallerSha256: DEF456
10474"#;
10475
10476        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10477
10478        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
10479        assert_eq!(
10480            manifest.installers[0].minimum_os_version.as_deref(),
10481            Some("10.0.19041.0")
10482        );
10483        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
10484        assert_eq!(
10485            manifest.installers[1].minimum_os_version.as_deref(),
10486            Some("10.0.22621.0")
10487        );
10488    }
10489
10490    #[test]
10491    fn installer_switch_arguments_prefer_manifest_switches() {
10492        let installer = Installer {
10493            architecture: None,
10494            installer_type: Some("inno".to_owned()),
10495            url: None,
10496            sha256: None,
10497            product_code: None,
10498            locale: None,
10499            scope: None,
10500            release_date: None,
10501            package_family_name: None,
10502            upgrade_code: None,
10503            platforms: Vec::new(),
10504            minimum_os_version: None,
10505            switches: InstallerSwitches {
10506                silent: Some("/mysilent".to_owned()),
10507                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
10508                interactive: Some("/myinteractive".to_owned()),
10509                ..InstallerSwitches::default()
10510            },
10511            commands: Vec::new(),
10512            package_dependencies: Vec::new(),
10513            require_explicit_upgrade: false,
10514            nested_installer_type: None,
10515            nested_installer_files: Vec::new(),
10516        };
10517        let manifest = Manifest {
10518            id: "Test.Package".to_owned(),
10519            name: "Test Package".to_owned(),
10520            version: "1.0.0".to_owned(),
10521            channel: String::new(),
10522            publisher: None,
10523            description: None,
10524            moniker: None,
10525            package_url: None,
10526            publisher_url: None,
10527            publisher_support_url: None,
10528            license: None,
10529            license_url: None,
10530            privacy_url: None,
10531            author: None,
10532            copyright: None,
10533            copyright_url: None,
10534            release_notes: None,
10535            release_notes_url: None,
10536            tags: Vec::new(),
10537            agreements: Vec::new(),
10538            package_dependencies: Vec::new(),
10539            documentation: Vec::new(),
10540            installers: Vec::new(),
10541            require_explicit_upgrade: false,
10542        };
10543
10544        let mut silent_request = InstallRequest::new(PackageQuery::default());
10545        silent_request.mode = InstallerMode::Silent;
10546        let mut progress_request = InstallRequest::new(PackageQuery::default());
10547        progress_request.mode = InstallerMode::SilentWithProgress;
10548        let mut interactive_request = InstallRequest::new(PackageQuery::default());
10549        interactive_request.mode = InstallerMode::Interactive;
10550        assert_eq!(
10551            installer_command_arguments(
10552                "inno",
10553                &silent_request,
10554                &manifest,
10555                Path::new("installer.exe"),
10556                &installer
10557            ),
10558            vec!["/mysilent".to_owned()]
10559        );
10560        assert_eq!(
10561            installer_command_arguments(
10562                "inno",
10563                &progress_request,
10564                &manifest,
10565                Path::new("installer.exe"),
10566                &installer
10567            ),
10568            vec!["/mysilentwithprogress".to_owned()]
10569        );
10570        assert_eq!(
10571            installer_command_arguments(
10572                "inno",
10573                &interactive_request,
10574                &manifest,
10575                Path::new("installer.exe"),
10576                &installer
10577            ),
10578            vec!["/myinteractive".to_owned()]
10579        );
10580    }
10581
10582    #[test]
10583    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
10584        let installer = Installer {
10585            architecture: None,
10586            installer_type: Some("inno".to_owned()),
10587            url: None,
10588            sha256: None,
10589            product_code: None,
10590            locale: None,
10591            scope: None,
10592            release_date: None,
10593            package_family_name: None,
10594            upgrade_code: None,
10595            platforms: Vec::new(),
10596            minimum_os_version: None,
10597            switches: InstallerSwitches::default(),
10598            commands: Vec::new(),
10599            package_dependencies: Vec::new(),
10600            require_explicit_upgrade: false,
10601            nested_installer_type: None,
10602            nested_installer_files: Vec::new(),
10603        };
10604        let manifest = Manifest {
10605            id: "Test.Package".to_owned(),
10606            name: "Test Package".to_owned(),
10607            version: "1.0.0".to_owned(),
10608            channel: String::new(),
10609            publisher: None,
10610            description: None,
10611            moniker: None,
10612            package_url: None,
10613            publisher_url: None,
10614            publisher_support_url: None,
10615            license: None,
10616            license_url: None,
10617            privacy_url: None,
10618            author: None,
10619            copyright: None,
10620            copyright_url: None,
10621            release_notes: None,
10622            release_notes_url: None,
10623            tags: Vec::new(),
10624            agreements: Vec::new(),
10625            package_dependencies: Vec::new(),
10626            documentation: Vec::new(),
10627            installers: Vec::new(),
10628            require_explicit_upgrade: false,
10629        };
10630        let mut progress_request = InstallRequest::new(PackageQuery::default());
10631        progress_request.mode = InstallerMode::SilentWithProgress;
10632        let mut silent_request = InstallRequest::new(PackageQuery::default());
10633        silent_request.mode = InstallerMode::Silent;
10634
10635        assert_eq!(
10636            installer_command_arguments(
10637                "inno",
10638                &progress_request,
10639                &manifest,
10640                Path::new("installer.exe"),
10641                &installer
10642            ),
10643            vec![
10644                "/SP-".to_owned(),
10645                "/SILENT".to_owned(),
10646                "/SUPPRESSMSGBOXES".to_owned(),
10647                "/NORESTART".to_owned()
10648            ]
10649        );
10650        assert_eq!(
10651            installer_command_arguments(
10652                "inno",
10653                &silent_request,
10654                &manifest,
10655                Path::new("installer.exe"),
10656                &installer
10657            ),
10658            vec![
10659                "/SP-".to_owned(),
10660                "/VERYSILENT".to_owned(),
10661                "/SUPPRESSMSGBOXES".to_owned(),
10662                "/NORESTART".to_owned()
10663            ]
10664        );
10665    }
10666
10667    #[test]
10668    fn installer_command_arguments_append_manifest_and_cli_switches() {
10669        let installer = Installer {
10670            architecture: None,
10671            installer_type: Some("msi".to_owned()),
10672            url: None,
10673            sha256: None,
10674            product_code: None,
10675            locale: None,
10676            scope: None,
10677            release_date: None,
10678            package_family_name: None,
10679            upgrade_code: None,
10680            platforms: Vec::new(),
10681            minimum_os_version: None,
10682            switches: InstallerSwitches {
10683                custom: Some("ADDLOCAL=Core".to_owned()),
10684                log: Some("/log \"<LOGPATH>\"".to_owned()),
10685                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
10686                ..InstallerSwitches::default()
10687            },
10688            commands: Vec::new(),
10689            package_dependencies: Vec::new(),
10690            require_explicit_upgrade: false,
10691            nested_installer_type: None,
10692            nested_installer_files: Vec::new(),
10693        };
10694        let manifest = Manifest {
10695            id: "ShareX.ShareX".to_owned(),
10696            name: "ShareX".to_owned(),
10697            version: "19.0.2".to_owned(),
10698            channel: String::new(),
10699            publisher: None,
10700            description: None,
10701            moniker: None,
10702            package_url: None,
10703            publisher_url: None,
10704            publisher_support_url: None,
10705            license: None,
10706            license_url: None,
10707            privacy_url: None,
10708            author: None,
10709            copyright: None,
10710            copyright_url: None,
10711            release_notes: None,
10712            release_notes_url: None,
10713            tags: Vec::new(),
10714            agreements: Vec::new(),
10715            package_dependencies: Vec::new(),
10716            documentation: Vec::new(),
10717            installers: Vec::new(),
10718            require_explicit_upgrade: false,
10719        };
10720        let mut request = InstallRequest::new(PackageQuery::default());
10721        request.mode = InstallerMode::Silent;
10722        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
10723        request.custom = Some("REBOOT=ReallySuppress".to_owned());
10724        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
10725
10726        assert_eq!(
10727            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
10728            vec![
10729                "/i".to_owned(),
10730                r"C:\temp\ShareX.msi".to_owned(),
10731                "/quiet".to_owned(),
10732                "/norestart".to_owned(),
10733                "/log".to_owned(),
10734                r"C:\temp\winget.log".to_owned(),
10735                "ADDLOCAL=Core".to_owned(),
10736                "REBOOT=ReallySuppress".to_owned(),
10737                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
10738            ]
10739        );
10740    }
10741
10742    #[test]
10743    fn decompresses_mszyml_payload() {
10744        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
10745        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10746        encoder.write_all(payload.as_bytes()).expect("write payload");
10747        let compressed = encoder.finish().expect("finish payload");
10748        let mut mszip = b"CK".to_vec();
10749        mszip.extend_from_slice(&compressed);
10750
10751        let decompressed = decompress_mszyml(&mszip).expect("decompress");
10752        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10753        assert_eq!(parsed.versions[0].version, "1.2.3");
10754    }
10755
10756    #[test]
10757    fn decompresses_mszyml_payload_with_prefix() {
10758        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
10759        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10760        encoder.write_all(payload.as_bytes()).expect("write payload");
10761        let compressed = encoder.finish().expect("finish payload");
10762        let mut prefixed = vec![0u8; 28];
10763        prefixed.extend_from_slice(b"CK");
10764        prefixed.extend_from_slice(&compressed);
10765
10766        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
10767        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10768        assert_eq!(parsed.versions[0].version, "1.2.3");
10769    }
10770
10771    #[test]
10772    fn compares_versions_naturally() {
10773        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
10774        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
10775        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
10776    }
10777
10778    #[test]
10779    fn installer_hash_verification_fails_on_mismatch_without_override() {
10780        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
10781        assert!(result.is_err());
10782        assert!(
10783            result
10784                .expect_err("hash mismatch")
10785                .to_string()
10786                .contains("Installer hash mismatch")
10787        );
10788    }
10789
10790    #[test]
10791    fn installer_hash_verification_allows_mismatch_with_override() {
10792        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
10793        verify_installer_hash(None, b"pinget", false).expect("missing hash");
10794    }
10795
10796    #[test]
10797    fn search_query_many_includes_tag_and_command_conditions() {
10798        let query = PackageQuery {
10799            query: Some("terminal".to_owned()),
10800            ..PackageQuery::default()
10801        };
10802
10803        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10804
10805        assert!(where_clause.contains("tags2"));
10806        assert!(where_clause.contains("commands2"));
10807        assert_eq!(params.len(), 5);
10808    }
10809
10810    #[test]
10811    fn show_query_single_omits_tag_and_command_conditions() {
10812        let query = PackageQuery {
10813            query: Some("terminal".to_owned()),
10814            ..PackageQuery::default()
10815        };
10816
10817        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
10818
10819        assert!(!where_clause.contains("tags2"));
10820        assert!(!where_clause.contains("commands2"));
10821        assert_eq!(params.len(), 3);
10822    }
10823
10824    #[test]
10825    fn explicit_tag_filter_uses_exact_match() {
10826        let query = PackageQuery {
10827            tag: Some("terminal".to_owned()),
10828            ..PackageQuery::default()
10829        };
10830
10831        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10832
10833        assert!(where_clause.contains("tags2"));
10834        assert_eq!(params, vec!["terminal".to_owned()]);
10835    }
10836
10837    #[test]
10838    fn rest_tag_filter_uses_exact_match() {
10839        let query = PackageQuery {
10840            tag: Some("terminal".to_owned()),
10841            ..PackageQuery::default()
10842        };
10843        let info = RestInformation {
10844            required_package_match_fields: Vec::new(),
10845            unsupported_package_match_fields: Vec::new(),
10846            required_query_parameters: Vec::new(),
10847            ..RestInformation::default()
10848        };
10849
10850        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
10851        let filters = body["Filters"].as_array().expect("filters");
10852
10853        assert_eq!(filters.len(), 1);
10854        assert_eq!(filters[0]["PackageMatchField"], "Tag");
10855        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
10856        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
10857    }
10858
10859    #[test]
10860    fn selects_installer_using_requested_filters() {
10861        let installers = vec![
10862            Installer {
10863                architecture: Some("x86".to_owned()),
10864                installer_type: Some("zip".to_owned()),
10865                url: None,
10866                sha256: None,
10867                product_code: None,
10868                locale: Some("en-US".to_owned()),
10869                scope: Some("user".to_owned()),
10870                release_date: None,
10871                package_family_name: None,
10872                upgrade_code: None,
10873                platforms: Vec::new(),
10874                minimum_os_version: None,
10875                switches: InstallerSwitches::default(),
10876                commands: Vec::new(),
10877                package_dependencies: Vec::new(),
10878                require_explicit_upgrade: false,
10879                nested_installer_type: None,
10880                nested_installer_files: Vec::new(),
10881            },
10882            Installer {
10883                architecture: Some("x64".to_owned()),
10884                installer_type: Some("msix".to_owned()),
10885                url: None,
10886                sha256: None,
10887                product_code: None,
10888                locale: Some("en-US".to_owned()),
10889                scope: Some("user".to_owned()),
10890                release_date: None,
10891                package_family_name: None,
10892                upgrade_code: None,
10893                platforms: Vec::new(),
10894                minimum_os_version: None,
10895                switches: InstallerSwitches::default(),
10896                commands: vec!["demo".to_owned()],
10897                package_dependencies: Vec::new(),
10898                require_explicit_upgrade: false,
10899                nested_installer_type: None,
10900                nested_installer_files: Vec::new(),
10901            },
10902        ];
10903        let query = PackageQuery {
10904            installer_type: Some("msix".to_owned()),
10905            installer_architecture: Some("x64".to_owned()),
10906            install_scope: Some("user".to_owned()),
10907            locale: Some("en-US".to_owned()),
10908            ..PackageQuery::default()
10909        };
10910
10911        let selected = select_installer(&installers, &query).expect("selected installer");
10912        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
10913        assert_eq!(selected.architecture.as_deref(), Some("x64"));
10914    }
10915
10916    #[test]
10917    fn selects_first_installer_when_rank_ties() {
10918        let installers = vec![
10919            Installer {
10920                architecture: Some("x64".to_owned()),
10921                installer_type: Some("exe".to_owned()),
10922                url: None,
10923                sha256: None,
10924                product_code: None,
10925                locale: Some("en-US".to_owned()),
10926                scope: Some("machine".to_owned()),
10927                release_date: None,
10928                package_family_name: None,
10929                upgrade_code: None,
10930                platforms: Vec::new(),
10931                minimum_os_version: None,
10932                switches: InstallerSwitches::default(),
10933                commands: Vec::new(),
10934                package_dependencies: Vec::new(),
10935                require_explicit_upgrade: false,
10936                nested_installer_type: None,
10937                nested_installer_files: Vec::new(),
10938            },
10939            Installer {
10940                architecture: Some("x64".to_owned()),
10941                installer_type: Some("exe".to_owned()),
10942                url: None,
10943                sha256: None,
10944                product_code: None,
10945                locale: Some("en-US".to_owned()),
10946                scope: Some("user".to_owned()),
10947                release_date: None,
10948                package_family_name: None,
10949                upgrade_code: None,
10950                platforms: Vec::new(),
10951                minimum_os_version: None,
10952                switches: InstallerSwitches::default(),
10953                commands: Vec::new(),
10954                package_dependencies: Vec::new(),
10955                require_explicit_upgrade: false,
10956                nested_installer_type: None,
10957                nested_installer_files: Vec::new(),
10958            },
10959        ];
10960
10961        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
10962        assert_eq!(selected.scope.as_deref(), Some("machine"));
10963    }
10964
10965    #[test]
10966    fn selects_installer_using_requested_platform() {
10967        let installers = vec![
10968            Installer {
10969                architecture: Some("x64".to_owned()),
10970                installer_type: Some("exe".to_owned()),
10971                url: None,
10972                sha256: None,
10973                product_code: None,
10974                locale: Some("en-US".to_owned()),
10975                scope: Some("user".to_owned()),
10976                release_date: None,
10977                package_family_name: None,
10978                upgrade_code: None,
10979                platforms: vec!["Windows.Universal".to_owned()],
10980                minimum_os_version: None,
10981                switches: InstallerSwitches::default(),
10982                commands: Vec::new(),
10983                package_dependencies: Vec::new(),
10984                require_explicit_upgrade: false,
10985                nested_installer_type: None,
10986                nested_installer_files: Vec::new(),
10987            },
10988            Installer {
10989                architecture: Some("x64".to_owned()),
10990                installer_type: Some("exe".to_owned()),
10991                url: None,
10992                sha256: None,
10993                product_code: None,
10994                locale: Some("en-US".to_owned()),
10995                scope: Some("user".to_owned()),
10996                release_date: None,
10997                package_family_name: None,
10998                upgrade_code: None,
10999                platforms: vec!["Windows.Desktop".to_owned()],
11000                minimum_os_version: None,
11001                switches: InstallerSwitches::default(),
11002                commands: Vec::new(),
11003                package_dependencies: Vec::new(),
11004                require_explicit_upgrade: false,
11005                nested_installer_type: None,
11006                nested_installer_files: Vec::new(),
11007            },
11008        ];
11009        let query = PackageQuery {
11010            installer_type: Some("exe".to_owned()),
11011            platform: Some("Windows.Desktop".to_owned()),
11012            ..PackageQuery::default()
11013        };
11014
11015        let selected = select_installer(&installers, &query).expect("selected installer");
11016        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
11017    }
11018
11019    #[test]
11020    fn selects_installer_using_requested_os_version() {
11021        let installers = vec![
11022            Installer {
11023                architecture: Some("x64".to_owned()),
11024                installer_type: Some("exe".to_owned()),
11025                url: None,
11026                sha256: None,
11027                product_code: None,
11028                locale: Some("en-US".to_owned()),
11029                scope: Some("user".to_owned()),
11030                release_date: None,
11031                package_family_name: None,
11032                upgrade_code: None,
11033                platforms: Vec::new(),
11034                minimum_os_version: Some("10.0.22621.0".to_owned()),
11035                switches: InstallerSwitches::default(),
11036                commands: Vec::new(),
11037                package_dependencies: Vec::new(),
11038                require_explicit_upgrade: false,
11039                nested_installer_type: None,
11040                nested_installer_files: Vec::new(),
11041            },
11042            Installer {
11043                architecture: Some("x64".to_owned()),
11044                installer_type: Some("exe".to_owned()),
11045                url: None,
11046                sha256: None,
11047                product_code: None,
11048                locale: Some("en-US".to_owned()),
11049                scope: Some("user".to_owned()),
11050                release_date: None,
11051                package_family_name: None,
11052                upgrade_code: None,
11053                platforms: Vec::new(),
11054                minimum_os_version: Some("10.0.19041.0".to_owned()),
11055                switches: InstallerSwitches::default(),
11056                commands: Vec::new(),
11057                package_dependencies: Vec::new(),
11058                require_explicit_upgrade: false,
11059                nested_installer_type: None,
11060                nested_installer_files: Vec::new(),
11061            },
11062        ];
11063        let query = PackageQuery {
11064            installer_type: Some("exe".to_owned()),
11065            os_version: Some("10.0.19045.0".to_owned()),
11066            ..PackageQuery::default()
11067        };
11068
11069        let selected = select_installer(&installers, &query).expect("selected installer");
11070        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
11071    }
11072
11073    #[test]
11074    fn derives_correlation_name_candidates() {
11075        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
11076        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
11077        assert!(candidates.contains(&"PowerToys".to_owned()));
11078    }
11079
11080    #[test]
11081    fn correlates_installed_package_to_available_match() {
11082        let installed = InstalledPackage {
11083            name: "PowerToys (Preview) x64".to_owned(),
11084            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11085            installed_version: "0.98.1".to_owned(),
11086            publisher: None,
11087            scope: Some("Machine".to_owned()),
11088            installer_category: Some("exe".to_owned()),
11089            install_location: None,
11090            package_family_names: Vec::new(),
11091            product_codes: Vec::new(),
11092            upgrade_codes: Vec::new(),
11093            correlated: None,
11094            installed_version_canonical: false,
11095            correlated_requires_explicit_upgrade: false,
11096            correlated_lacks_compatible_installer: false,
11097        };
11098        let candidates = vec![SearchMatch {
11099            source_name: "winget".to_owned(),
11100            source_kind: SourceKind::PreIndexed,
11101            id: "Microsoft.PowerToys".to_owned(),
11102            name: "PowerToys".to_owned(),
11103            moniker: None,
11104            version: Some("0.98.1".to_owned()),
11105            channel: None,
11106            match_criteria: None,
11107        }];
11108
11109        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11110        assert_eq!(correlated.id, "Microsoft.PowerToys");
11111    }
11112
11113    #[test]
11114    fn list_query_uses_available_lookup_for_tag_filters() {
11115        let query = ListQuery {
11116            tag: Some("terminal".to_owned()),
11117            ..ListQuery::default()
11118        };
11119
11120        assert!(list_query_needs_available_lookup(&query));
11121    }
11122
11123    #[test]
11124    fn list_tag_filter_requires_correlated_match() {
11125        let package = InstalledPackage {
11126            name: "PowerToys (Preview) x64".to_owned(),
11127            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11128            installed_version: "0.98.1".to_owned(),
11129            publisher: None,
11130            scope: Some("Machine".to_owned()),
11131            installer_category: Some("exe".to_owned()),
11132            install_location: None,
11133            package_family_names: Vec::new(),
11134            product_codes: Vec::new(),
11135            upgrade_codes: Vec::new(),
11136            correlated: None,
11137            installed_version_canonical: false,
11138            correlated_requires_explicit_upgrade: false,
11139            correlated_lacks_compatible_installer: false,
11140        };
11141        let query = ListQuery {
11142            tag: Some("powertoys".to_owned()),
11143            ..ListQuery::default()
11144        };
11145
11146        assert!(!list_package_matches(&package, &query));
11147    }
11148
11149    #[test]
11150    fn list_lookup_ignores_display_count() {
11151        let query = ListQuery {
11152            query: Some("git".to_owned()),
11153            count: Some(5),
11154            ..ListQuery::default()
11155        };
11156
11157        let package_query = package_query_from_list_query(&query);
11158        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
11159    }
11160
11161    #[test]
11162    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
11163        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
11164        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
11165        // whichever candidate happened to come last in iteration order. The installed
11166        // display name's prefix decides the correct one.
11167        let installed = InstalledPackage {
11168            name: "Notepad++ (ARM 64-bit)".to_owned(),
11169            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
11170            installed_version: "8.8.9".to_owned(),
11171            publisher: None,
11172            scope: Some("Machine".to_owned()),
11173            installer_category: Some("exe".to_owned()),
11174            install_location: None,
11175            package_family_names: Vec::new(),
11176            product_codes: Vec::new(),
11177            upgrade_codes: Vec::new(),
11178            correlated: None,
11179            installed_version_canonical: false,
11180            correlated_requires_explicit_upgrade: false,
11181            correlated_lacks_compatible_installer: false,
11182        };
11183        let candidates = vec![
11184            SearchMatch {
11185                source_name: "winget".to_owned(),
11186                source_kind: SourceKind::PreIndexed,
11187                id: "ndd.Notepad--".to_owned(),
11188                name: "Notepad--".to_owned(),
11189                moniker: None,
11190                version: Some("1.0.0".to_owned()),
11191                channel: None,
11192                match_criteria: None,
11193            },
11194            SearchMatch {
11195                source_name: "winget".to_owned(),
11196                source_kind: SourceKind::PreIndexed,
11197                id: "Notepad++.Notepad++".to_owned(),
11198                name: "Notepad++".to_owned(),
11199                moniker: None,
11200                version: Some("8.9.5".to_owned()),
11201                channel: None,
11202                match_criteria: None,
11203            },
11204        ];
11205
11206        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11207        assert_eq!(correlated.id, "Notepad++.Notepad++");
11208    }
11209
11210    #[test]
11211    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
11212        // Without the prefix bonus, the loose substring match picked up `Studio`
11213        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
11214        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
11215        // sort order favored the alphabetically-later candidate.
11216        let installed = InstalledPackage {
11217            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
11218            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
11219            installed_version: "40.10.18029".to_owned(),
11220            publisher: None,
11221            scope: Some("Machine".to_owned()),
11222            installer_category: Some("msi".to_owned()),
11223            install_location: None,
11224            package_family_names: Vec::new(),
11225            product_codes: Vec::new(),
11226            upgrade_codes: Vec::new(),
11227            correlated: None,
11228            installed_version_canonical: false,
11229            correlated_requires_explicit_upgrade: false,
11230            correlated_lacks_compatible_installer: false,
11231        };
11232        let candidates = vec![
11233            SearchMatch {
11234                source_name: "winget".to_owned(),
11235                source_kind: SourceKind::PreIndexed,
11236                id: "ZeroBrane.Studio".to_owned(),
11237                name: "Studio".to_owned(),
11238                moniker: None,
11239                version: Some("1.0".to_owned()),
11240                channel: None,
11241                match_criteria: None,
11242            },
11243            SearchMatch {
11244                source_name: "winget".to_owned(),
11245                source_kind: SourceKind::PreIndexed,
11246                id: "BrickLink.Studio".to_owned(),
11247                name: "Studio".to_owned(),
11248                moniker: None,
11249                version: Some("1.0".to_owned()),
11250                channel: None,
11251                match_criteria: None,
11252            },
11253            SearchMatch {
11254                source_name: "winget".to_owned(),
11255                source_kind: SourceKind::PreIndexed,
11256                id: "Microsoft.DotNet.SDK.10".to_owned(),
11257                name: "Microsoft .NET SDK 10.0".to_owned(),
11258                moniker: None,
11259                version: Some("10.0.204".to_owned()),
11260                channel: None,
11261                match_criteria: None,
11262            },
11263        ];
11264
11265        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11266        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
11267    }
11268
11269    #[test]
11270    fn msix_packages_do_not_correlate_via_name() {
11271        // MSIX correlation must go through the v2 index's `pfns2` table — name
11272        // fallback is wrong because two MSIX packages can legitimately share a
11273        // display name without sharing identity (`Microsoft Edge Stable` MSIX
11274        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
11275        // the catalog Inno installer). Even when the names match exactly the
11276        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
11277        // PFN hit; that lookup happens in `correlate_installed_via_index`.
11278        let installed = InstalledPackage {
11279            name: "Microsoft Teams".to_owned(),
11280            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
11281            installed_version: "25290.205.4069.4894".to_owned(),
11282            publisher: None,
11283            scope: Some("User".to_owned()),
11284            installer_category: Some("msix".to_owned()),
11285            install_location: None,
11286            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
11287            product_codes: Vec::new(),
11288            upgrade_codes: Vec::new(),
11289            correlated: None,
11290            installed_version_canonical: false,
11291            correlated_requires_explicit_upgrade: false,
11292            correlated_lacks_compatible_installer: false,
11293        };
11294        let candidates = vec![SearchMatch {
11295            source_name: "winget".to_owned(),
11296            source_kind: SourceKind::PreIndexed,
11297            id: "Microsoft.Teams".to_owned(),
11298            name: "Microsoft Teams".to_owned(),
11299            moniker: None,
11300            version: Some("26106.1906.4665.7308".to_owned()),
11301            channel: None,
11302            match_criteria: None,
11303        }];
11304
11305        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11306    }
11307
11308    #[test]
11309    fn name_fallback_refuses_ambiguous_winners() {
11310        // The user has Git installed (publisher "The Git Development Community"
11311        // — but publisher isn't in the scoring function). Two catalog packages
11312        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
11313        // publisher disambiguation they score identically; winget refuses to
11314        // correlate (the install lists with empty Source). pinget must do the
11315        // same to avoid manufacturing an upgrade against the wrong catalog
11316        // package.
11317        let installed = InstalledPackage {
11318            name: "Git".to_owned(),
11319            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
11320            installed_version: "2.53.0".to_owned(),
11321            publisher: Some("The Git Development Community".to_owned()),
11322            scope: Some("Machine".to_owned()),
11323            installer_category: Some("exe".to_owned()),
11324            install_location: None,
11325            package_family_names: Vec::new(),
11326            product_codes: Vec::new(),
11327            upgrade_codes: Vec::new(),
11328            correlated: None,
11329            installed_version_canonical: false,
11330            correlated_requires_explicit_upgrade: false,
11331            correlated_lacks_compatible_installer: false,
11332        };
11333        let candidates = vec![
11334            SearchMatch {
11335                source_name: "winget".to_owned(),
11336                source_kind: SourceKind::PreIndexed,
11337                id: "Git.Git".to_owned(),
11338                name: "Git".to_owned(),
11339                moniker: None,
11340                version: Some("2.54.0".to_owned()),
11341                channel: None,
11342                match_criteria: None,
11343            },
11344            SearchMatch {
11345                source_name: "winget".to_owned(),
11346                source_kind: SourceKind::PreIndexed,
11347                id: "Microsoft.Git".to_owned(),
11348                name: "Git".to_owned(),
11349                moniker: None,
11350                version: Some("2.53.0.0.7".to_owned()),
11351                channel: None,
11352                match_criteria: None,
11353            },
11354        ];
11355
11356        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11357    }
11358
11359    #[test]
11360    fn msix_with_resource_string_name_does_not_correlate() {
11361        // Some MSIX entries have unresolved resource-string display names
11362        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
11363        // unrelated catalog package via the loose substring rule.
11364        let installed = InstalledPackage {
11365            name: "ms-resource:appDisplayName".to_owned(),
11366            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
11367            installed_version: "1.28.239.0".to_owned(),
11368            publisher: None,
11369            scope: Some("User".to_owned()),
11370            installer_category: Some("msix".to_owned()),
11371            install_location: None,
11372            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
11373            product_codes: Vec::new(),
11374            upgrade_codes: Vec::new(),
11375            correlated: None,
11376            installed_version_canonical: false,
11377            correlated_requires_explicit_upgrade: false,
11378            correlated_lacks_compatible_installer: false,
11379        };
11380        let candidates = vec![SearchMatch {
11381            source_name: "winget".to_owned(),
11382            source_kind: SourceKind::PreIndexed,
11383            id: "Microsoft.AppInstaller".to_owned(),
11384            name: "App Installer".to_owned(),
11385            moniker: None,
11386            version: Some("1.28.240.0".to_owned()),
11387            channel: None,
11388            match_criteria: None,
11389        }];
11390
11391        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11392    }
11393
11394    #[test]
11395    fn strict_list_correlation_avoids_short_substring_matches() {
11396        let installed = InstalledPackage {
11397            name: "GitHub".to_owned(),
11398            local_id: r"ARP\User\X64\GitHub".to_owned(),
11399            installed_version: "1.0.0".to_owned(),
11400            publisher: None,
11401            scope: Some("User".to_owned()),
11402            installer_category: Some("exe".to_owned()),
11403            install_location: None,
11404            package_family_names: Vec::new(),
11405            product_codes: Vec::new(),
11406            upgrade_codes: Vec::new(),
11407            correlated: None,
11408            installed_version_canonical: false,
11409            correlated_requires_explicit_upgrade: false,
11410            correlated_lacks_compatible_installer: false,
11411        };
11412        let candidates = vec![SearchMatch {
11413            source_name: "winget".to_owned(),
11414            source_kind: SourceKind::PreIndexed,
11415            id: "Git.Git.PreRelease".to_owned(),
11416            name: "Git".to_owned(),
11417            moniker: None,
11418            version: Some("2.54.0".to_owned()),
11419            channel: None,
11420            match_criteria: None,
11421        }];
11422
11423        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
11424    }
11425
11426    #[test]
11427    fn detects_available_upgrade_from_correlated_package() {
11428        let package = InstalledPackage {
11429            name: "AzCopy v10".to_owned(),
11430            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
11431            installed_version: "10.32.2".to_owned(),
11432            publisher: None,
11433            scope: Some("Machine".to_owned()),
11434            installer_category: Some("exe".to_owned()),
11435            install_location: None,
11436            package_family_names: Vec::new(),
11437            product_codes: Vec::new(),
11438            upgrade_codes: Vec::new(),
11439            correlated: Some(SearchMatch {
11440                source_name: "winget".to_owned(),
11441                source_kind: SourceKind::PreIndexed,
11442                id: "Microsoft.Azure.AZCopy.10".to_owned(),
11443                name: "AzCopy v10".to_owned(),
11444                moniker: None,
11445                version: Some("10.32.3".to_owned()),
11446                channel: None,
11447                match_criteria: None,
11448            }),
11449            installed_version_canonical: false,
11450            correlated_requires_explicit_upgrade: false,
11451            correlated_lacks_compatible_installer: false,
11452        };
11453
11454        assert!(installed_package_has_upgrade(&package));
11455    }
11456
11457    #[test]
11458    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
11459        let package = InstalledPackage {
11460            name: "Example Tool".to_owned(),
11461            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
11462            installed_version: "Unknown".to_owned(),
11463            publisher: None,
11464            scope: Some("User".to_owned()),
11465            installer_category: Some("exe".to_owned()),
11466            install_location: None,
11467            package_family_names: Vec::new(),
11468            product_codes: Vec::new(),
11469            upgrade_codes: Vec::new(),
11470            correlated: Some(SearchMatch {
11471                source_name: "winget".to_owned(),
11472                source_kind: SourceKind::PreIndexed,
11473                id: "Contoso.ExampleTool".to_owned(),
11474                name: "Example Tool".to_owned(),
11475                moniker: None,
11476                version: Some("2.0.0".to_owned()),
11477                channel: None,
11478                match_criteria: None,
11479            }),
11480            installed_version_canonical: false,
11481            correlated_requires_explicit_upgrade: false,
11482            correlated_lacks_compatible_installer: false,
11483        };
11484        let query = ListQuery {
11485            upgrade_only: true,
11486            include_unknown: true,
11487            ..Default::default()
11488        };
11489
11490        assert!(installed_package_matches_upgrade_filter(&package, &query));
11491        assert_eq!(
11492            list_match_from_installed(package).available_version.as_deref(),
11493            Some("2.0.0")
11494        );
11495    }
11496
11497    #[test]
11498    fn create_repair_list_query_includes_installed_selectors() {
11499        let request = RepairRequest {
11500            query: PackageQuery {
11501                query: Some("powertoys".to_owned()),
11502                id: Some("Microsoft.PowerToys".to_owned()),
11503                name: Some("PowerToys".to_owned()),
11504                moniker: Some("powertoys".to_owned()),
11505                source: Some("winget".to_owned()),
11506                exact: true,
11507                version: Some("0.98.1".to_owned()),
11508                install_scope: Some("Machine".to_owned()),
11509                ..PackageQuery::default()
11510            },
11511            manifest_path: None,
11512            product_code: Some("{GUID}".to_owned()),
11513            mode: InstallerMode::Silent,
11514            log_path: Some(PathBuf::from("repair.log")),
11515            accept_package_agreements: true,
11516            force: true,
11517            ignore_security_hash: true,
11518        };
11519
11520        let query = Repository::create_repair_list_query(&request);
11521        assert_eq!(query.query.as_deref(), Some("powertoys"));
11522        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
11523        assert_eq!(query.name.as_deref(), Some("PowerToys"));
11524        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
11525        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
11526        assert_eq!(query.version.as_deref(), Some("0.98.1"));
11527        assert_eq!(query.source.as_deref(), Some("winget"));
11528        assert!(query.exact);
11529        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
11530        assert_eq!(query.count, Some(100));
11531    }
11532
11533    #[test]
11534    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
11535        let request = RepairRequest {
11536            query: PackageQuery {
11537                name: Some("PowerToys".to_owned()),
11538                source: Some("winget".to_owned()),
11539                locale: Some("en-US".to_owned()),
11540                installer_architecture: Some("x64".to_owned()),
11541                install_scope: Some("Machine".to_owned()),
11542                ..PackageQuery::default()
11543            },
11544            manifest_path: None,
11545            product_code: None,
11546            mode: InstallerMode::SilentWithProgress,
11547            log_path: Some(PathBuf::from("repair.log")),
11548            accept_package_agreements: true,
11549            force: false,
11550            ignore_security_hash: true,
11551        };
11552        let installed = ListMatch {
11553            name: "PowerToys".to_owned(),
11554            id: "Microsoft.PowerToys".to_owned(),
11555            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
11556            installed_version: "0.98.1".to_owned(),
11557            available_version: Some("0.98.2".to_owned()),
11558            source_name: Some("winget".to_owned()),
11559            publisher: None,
11560            scope: Some("Machine".to_owned()),
11561            installer_category: Some("exe".to_owned()),
11562            install_location: None,
11563            package_family_names: Vec::new(),
11564            product_codes: Vec::new(),
11565            upgrade_codes: Vec::new(),
11566        };
11567
11568        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
11569        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
11570        assert!(install_request.query.query.is_none());
11571        assert!(install_request.query.name.is_none());
11572        assert!(install_request.query.moniker.is_none());
11573        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
11574        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
11575        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
11576        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
11577        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
11578        assert!(install_request.query.exact);
11579        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
11580        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
11581        assert!(install_request.accept_package_agreements);
11582        assert!(install_request.force);
11583        assert!(install_request.ignore_security_hash);
11584    }
11585
11586    #[test]
11587    fn source_scoped_pin_operations_round_trip() {
11588        let app_root = temp_app_root("pins");
11589        let result = (|| -> Result<()> {
11590            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11591            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
11592            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
11593            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
11594
11595            assert_eq!(repository.list_pins(None)?.len(), 3);
11596            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
11597            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
11598
11599            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
11600            assert_eq!(repository.list_pins(None)?.len(), 2);
11601            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
11602
11603            repository.reset_pins(Some("test"))?;
11604            let remaining = repository.list_pins(None)?;
11605            assert_eq!(remaining.len(), 1);
11606            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
11607
11608            repository.reset_pins(None)?;
11609            assert!(repository.list_pins(None)?.is_empty());
11610            Ok(())
11611        })();
11612
11613        let _ = fs::remove_dir_all(&app_root);
11614        result.expect("pin round trip");
11615    }
11616
11617    #[test]
11618    fn pin_operations_work_with_packaged_pin_schema() {
11619        let app_root = temp_app_root("pins_packaged_schema");
11620        let result = (|| -> Result<()> {
11621            let db_path = pins_db_path(&app_root);
11622            if let Some(parent) = db_path.parent() {
11623                fs::create_dir_all(parent)?;
11624            }
11625
11626            let conn = Repository::open_sqlite_connection(db_path.clone())?;
11627            execute_batch_sql(
11628                &conn,
11629                "CREATE TABLE pin (
11630                    package_id TEXT NOT NULL,
11631                    source_id TEXT NOT NULL,
11632                    type INTEGER NOT NULL,
11633                    version TEXT NOT NULL,
11634                    PRIMARY KEY (package_id, source_id)
11635                )",
11636            )?;
11637            conn.cacheflush().context("failed to flush pin database")?;
11638            drop(conn);
11639
11640            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11641            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
11642
11643            let pins = repository.list_pins(None)?;
11644            assert_eq!(pins.len(), 1);
11645            assert_eq!(pins[0].package_id, "Contoso.Tool");
11646            assert_eq!(pins[0].version, "1.2.*");
11647            assert_eq!(pins[0].source_id, "winget");
11648            assert_eq!(pins[0].pin_type, PinType::Gating);
11649
11650            let conn = Repository::open_sqlite_connection(db_path)?;
11651            let stored_pin = query_optional_value(
11652                &conn,
11653                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
11654                vec![
11655                    SqlValue::Text("Contoso.Tool".to_owned()),
11656                    SqlValue::Text("winget".to_owned()),
11657                ],
11658                |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
11659            )?
11660            .ok_or_else(|| anyhow!("expected stored pin"))?;
11661            assert_eq!(stored_pin.0, 3);
11662            assert_eq!(stored_pin.1, "1.2.*");
11663
11664            Ok(())
11665        })();
11666
11667        let _ = fs::remove_dir_all(&app_root);
11668        result.expect("packaged pin schema round trip");
11669    }
11670
11671    #[test]
11672    fn version_pin_patterns_match_exact_and_prefix_values() {
11673        assert!(version_matches_pin_pattern("1.2.3", "*"));
11674        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
11675        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
11676        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
11677        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
11678    }
11679
11680    #[test]
11681    fn source_specific_pins_override_source_agnostic_pins() {
11682        let item = ListMatch {
11683            name: "Contoso Tool".to_owned(),
11684            id: "Contoso.Tool".to_owned(),
11685            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11686            installed_version: "1.0.0".to_owned(),
11687            available_version: Some("2.0.0".to_owned()),
11688            source_name: Some("winget".to_owned()),
11689            publisher: None,
11690            scope: None,
11691            installer_category: None,
11692            install_location: None,
11693            package_family_names: Vec::new(),
11694            product_codes: Vec::new(),
11695            upgrade_codes: Vec::new(),
11696        };
11697        let pins = vec![
11698            PinRecord {
11699                package_id: "Contoso.Tool".to_owned(),
11700                version: "*".to_owned(),
11701                source_id: String::new(),
11702                pin_type: PinType::Blocking,
11703            },
11704            PinRecord {
11705                package_id: "Contoso.Tool".to_owned(),
11706                version: "2.0.*".to_owned(),
11707                source_id: "winget".to_owned(),
11708                pin_type: PinType::Pinning,
11709            },
11710        ];
11711
11712        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
11713    }
11714
11715    #[test]
11716    fn blocking_pin_blocks_upgrade() {
11717        let item = ListMatch {
11718            name: "Contoso Tool".to_owned(),
11719            id: "Contoso.Tool".to_owned(),
11720            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11721            installed_version: "1.0.0".to_owned(),
11722            available_version: Some("2.0.0".to_owned()),
11723            source_name: Some("winget".to_owned()),
11724            publisher: None,
11725            scope: None,
11726            installer_category: None,
11727            install_location: None,
11728            package_family_names: Vec::new(),
11729            product_codes: Vec::new(),
11730            upgrade_codes: Vec::new(),
11731        };
11732        let pins = vec![PinRecord {
11733            package_id: "Contoso.Tool".to_owned(),
11734            version: "*".to_owned(),
11735            source_id: "winget".to_owned(),
11736            pin_type: PinType::Blocking,
11737        }];
11738
11739        assert!(is_upgrade_blocked_by_pin(&item, &pins));
11740    }
11741
11742    #[cfg(windows)]
11743    #[test]
11744    fn parses_msix_package_full_name_into_version_and_family() {
11745        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
11746            .expect("package metadata");
11747
11748        assert_eq!(parsed.version, "0.98.1.0");
11749        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
11750    }
11751
11752    #[cfg(windows)]
11753    #[test]
11754    fn recognizes_windows_system_paths() {
11755        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
11756        assert!(!is_windows_system_path(
11757            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
11758        ));
11759    }
11760
11761    #[test]
11762    fn search_ranking_prefers_exact_name_over_tag_match() {
11763        let query = PackageQuery {
11764            query: Some("PowerToys".to_owned()),
11765            ..Default::default()
11766        };
11767        let exact_name = SearchMatch {
11768            source_name: "winget".to_owned(),
11769            source_kind: SourceKind::PreIndexed,
11770            id: "Microsoft.PowerToys".to_owned(),
11771            name: "PowerToys".to_owned(),
11772            moniker: Some("powertoys".to_owned()),
11773            version: Some("0.98.1".to_owned()),
11774            channel: None,
11775            match_criteria: Some("Moniker: powertoys".to_owned()),
11776        };
11777        let tag_match = SearchMatch {
11778            source_name: "winget".to_owned(),
11779            source_kind: SourceKind::PreIndexed,
11780            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
11781            name: "QR Codes for Command Palette".to_owned(),
11782            moniker: None,
11783            version: Some("0.4.0.0".to_owned()),
11784            channel: None,
11785            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
11786        };
11787
11788        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
11789    }
11790
11791    #[test]
11792    fn select_best_text_match_prefers_exact_tag_value() {
11793        let values = vec![
11794            "microsoft-powertoys".to_owned(),
11795            "powertoys-run".to_owned(),
11796            "powertoys".to_owned(),
11797        ];
11798
11799        assert_eq!(
11800            select_best_text_match(values, "PowerToys", false).as_deref(),
11801            Some("powertoys")
11802        );
11803    }
11804
11805    #[test]
11806    fn search_match_criteria_is_blank_for_name_match() {
11807        let query = PackageQuery {
11808            query: Some("PowerToys".to_owned()),
11809            ..Default::default()
11810        };
11811
11812        let result = infer_match_criteria(
11813            "Microsoft.PowerToys",
11814            "PowerToys",
11815            Some("powertoys"),
11816            &query,
11817            SearchSemantics::Many,
11818            |_| Ok(Some("powertoys".to_owned())),
11819            |_| Ok(None),
11820        )
11821        .expect("match criteria");
11822
11823        assert_eq!(result, None);
11824    }
11825
11826    #[test]
11827    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
11828        let query = PackageQuery {
11829            query: Some("PowerToys".to_owned()),
11830            ..Default::default()
11831        };
11832        let unknown_version = SearchMatch {
11833            source_name: "msstore".to_owned(),
11834            source_kind: SourceKind::Rest,
11835            id: "XP89DCGQ3K6VLD".to_owned(),
11836            name: "Microsoft PowerToys".to_owned(),
11837            moniker: None,
11838            version: Some("Unknown".to_owned()),
11839            channel: None,
11840            match_criteria: None,
11841        };
11842        let tag_match = SearchMatch {
11843            source_name: "winget".to_owned(),
11844            source_kind: SourceKind::PreIndexed,
11845            id: "Riri.QRCodeforCmdPal".to_owned(),
11846            name: "QR Code for CmdPal".to_owned(),
11847            moniker: None,
11848            version: Some("0.0.1.0".to_owned()),
11849            channel: None,
11850            match_criteria: Some("Tag: powertoys".to_owned()),
11851        };
11852
11853        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
11854    }
11855
11856    #[test]
11857    fn search_source_fetch_results_ignore_small_display_count() {
11858        let query = PackageQuery {
11859            count: Some(5),
11860            ..Default::default()
11861        };
11862
11863        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
11864        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
11865    }
11866
11867    #[test]
11868    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
11869        let query = PackageQuery {
11870            query: Some("PowerToys".to_owned()),
11871            ..Default::default()
11872        };
11873        let prefix_name = SearchMatch {
11874            source_name: "winget".to_owned(),
11875            source_kind: SourceKind::PreIndexed,
11876            id: "advaith.CurrencyConverterPowerToys".to_owned(),
11877            name: "PowerToys-Run-Currency-Converter".to_owned(),
11878            moniker: None,
11879            version: Some("1.5.4".to_owned()),
11880            channel: None,
11881            match_criteria: None,
11882        };
11883        let exact_tag = SearchMatch {
11884            source_name: "winget".to_owned(),
11885            source_kind: SourceKind::PreIndexed,
11886            id: "Riri.QRCodeforCmdPal".to_owned(),
11887            name: "QR Code for CmdPal".to_owned(),
11888            moniker: None,
11889            version: Some("0.0.1.0".to_owned()),
11890            channel: None,
11891            match_criteria: Some("Tag: powertoys".to_owned()),
11892        };
11893
11894        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
11895    }
11896
11897    #[test]
11898    fn list_sort_prefers_main_package_then_sparse_app() {
11899        let main = InstalledPackage {
11900            name: "PowerToys (Preview) x64".to_owned(),
11901            local_id: r"ARP\User\X64\PowerToys".to_owned(),
11902            installed_version: "0.98.1".to_owned(),
11903            publisher: None,
11904            scope: Some("User".to_owned()),
11905            installer_category: Some("exe".to_owned()),
11906            install_location: None,
11907            package_family_names: Vec::new(),
11908            product_codes: Vec::new(),
11909            upgrade_codes: Vec::new(),
11910            correlated: None,
11911            installed_version_canonical: false,
11912            correlated_requires_explicit_upgrade: false,
11913            correlated_lacks_compatible_installer: false,
11914        };
11915        let sparse = InstalledPackage {
11916            name: "PowerToys.SparseApp".to_owned(),
11917            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
11918            installed_version: "0.98.1.0".to_owned(),
11919            publisher: None,
11920            scope: Some("User".to_owned()),
11921            installer_category: Some("msix".to_owned()),
11922            install_location: None,
11923            package_family_names: Vec::new(),
11924            product_codes: Vec::new(),
11925            upgrade_codes: Vec::new(),
11926            correlated: None,
11927            installed_version_canonical: false,
11928            correlated_requires_explicit_upgrade: false,
11929            correlated_lacks_compatible_installer: false,
11930        };
11931        let extension = InstalledPackage {
11932            name: "PowerToys FileLocksmith Context Menu".to_owned(),
11933            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
11934            installed_version: "0.98.1.0".to_owned(),
11935            publisher: None,
11936            scope: Some("User".to_owned()),
11937            installer_category: Some("msix".to_owned()),
11938            install_location: None,
11939            package_family_names: Vec::new(),
11940            product_codes: Vec::new(),
11941            upgrade_codes: Vec::new(),
11942            correlated: None,
11943            installed_version_canonical: false,
11944            correlated_requires_explicit_upgrade: false,
11945            correlated_lacks_compatible_installer: false,
11946        };
11947
11948        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
11949        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
11950    }
11951
11952    #[test]
11953    #[cfg(windows)]
11954    fn arp_subkey_name_extracts_registry_subkey() {
11955        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
11956        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
11957    }
11958
11959    #[test]
11960    #[cfg(windows)]
11961    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
11962        let installed = ListMatch {
11963            name: "ShareX".to_owned(),
11964            id: "ShareX.ShareX".to_owned(),
11965            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
11966            installed_version: "19.0.2".to_owned(),
11967            available_version: None,
11968            source_name: Some("winget".to_owned()),
11969            publisher: Some("ShareX Team".to_owned()),
11970            scope: Some("Machine".to_owned()),
11971            installer_category: Some("exe".to_owned()),
11972            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
11973            package_family_names: Vec::new(),
11974            product_codes: Vec::new(),
11975            upgrade_codes: Vec::new(),
11976        };
11977
11978        assert!(registry_entry_matches_installed_package(
11979            "ShareX", "ShareX", None, &installed
11980        ));
11981        assert!(!registry_entry_matches_installed_package(
11982            "ShareX.ShareX",
11983            "ShareX.ShareX",
11984            None,
11985            &installed
11986        ));
11987    }
11988
11989    #[test]
11990    #[cfg(windows)]
11991    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
11992        assert_eq!(
11993            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
11994            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
11995        );
11996        assert_eq!(
11997            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
11998            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
11999        );
12000        assert_eq!(
12001            r#""C:\Program Files\ShareX\unins000.exe""#,
12002            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
12003        );
12004        assert_eq!(
12005            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12006            build_uninstall_command(
12007                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12008                true,
12009                false,
12010            )
12011        );
12012    }
12013
12014    #[test]
12015    #[cfg(windows)]
12016    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
12017        let installed = ListMatch {
12018            name: "lazygit".to_owned(),
12019            id: "JesseDuffield.lazygit".to_owned(),
12020            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
12021            installed_version: "0.61.1".to_owned(),
12022            available_version: None,
12023            source_name: Some("winget".to_owned()),
12024            publisher: Some("Jesse Duffield".to_owned()),
12025            scope: Some("User".to_owned()),
12026            installer_category: Some("exe".to_owned()),
12027            install_location: Some(
12028                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
12029            ),
12030            package_family_names: Vec::new(),
12031            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
12032            upgrade_codes: Vec::new(),
12033        };
12034        let mut request = UninstallRequest::new(PackageQuery {
12035            id: Some("JesseDuffield.lazygit".to_owned()),
12036            install_scope: Some("user".to_owned()),
12037            ..PackageQuery::default()
12038        });
12039        request.mode = InstallerMode::Silent;
12040
12041        assert_eq!(
12042            Some(vec![
12043                "uninstall".to_owned(),
12044                "--id".to_owned(),
12045                "JesseDuffield.lazygit".to_owned(),
12046                "--exact".to_owned(),
12047                "--disable-interactivity".to_owned(),
12048                "--source".to_owned(),
12049                "winget".to_owned(),
12050                "--scope".to_owned(),
12051                "user".to_owned(),
12052                "--silent".to_owned(),
12053            ]),
12054            build_winget_uninstall_arguments(&installed, &request)
12055        );
12056
12057        assert_eq!(
12058            Some(vec![
12059                "uninstall".to_owned(),
12060                "--id".to_owned(),
12061                "JesseDuffield.lazygit".to_owned(),
12062                "--exact".to_owned(),
12063                "--disable-interactivity".to_owned(),
12064                "--source".to_owned(),
12065                "winget".to_owned(),
12066                "--silent".to_owned(),
12067            ]),
12068            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
12069        );
12070    }
12071
12072    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
12073        PackageVersionDataEntry {
12074            version: version.to_owned(),
12075            manifest_relative_path: format!("manifests/x/{version}"),
12076            manifest_hash: "00".repeat(32),
12077            arp_min_version: arp_min.map(str::to_owned),
12078            arp_max_version: arp_max.map(str::to_owned),
12079        }
12080    }
12081
12082    fn v1_version(version: &str, channel: &str) -> V1VersionRow {
12083        V1VersionRow {
12084            version: version.to_owned(),
12085            channel: channel.to_owned(),
12086            pathpart_id: 0,
12087            manifest_hash: None,
12088        }
12089    }
12090
12091    #[derive(Clone)]
12092    struct TestHttpResponse {
12093        status: u16,
12094        body: Vec<u8>,
12095        source_version: Option<String>,
12096    }
12097
12098    struct TestHttpServer {
12099        addr: SocketAddr,
12100        routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12101        counts: Arc<Mutex<HashMap<String, usize>>>,
12102        running: Arc<AtomicBool>,
12103        handle: Option<JoinHandle<()>>,
12104    }
12105
12106    impl TestHttpServer {
12107        fn start() -> Self {
12108            let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
12109            let addr = listener.local_addr().expect("test HTTP server address");
12110            let routes = Arc::new(Mutex::new(HashMap::new()));
12111            let counts = Arc::new(Mutex::new(HashMap::new()));
12112            let running = Arc::new(AtomicBool::new(true));
12113            let thread_routes = Arc::clone(&routes);
12114            let thread_counts = Arc::clone(&counts);
12115            let thread_running = Arc::clone(&running);
12116            let handle = thread::spawn(move || {
12117                while thread_running.load(AtomicOrdering::SeqCst) {
12118                    let Ok((stream, _)) = listener.accept() else {
12119                        continue;
12120                    };
12121                    handle_test_http_request(stream, &thread_routes, &thread_counts);
12122                }
12123            });
12124
12125            Self {
12126                addr,
12127                routes,
12128                counts,
12129                running,
12130                handle: Some(handle),
12131            }
12132        }
12133
12134        fn url(&self) -> String {
12135            format!("http://{}", self.addr)
12136        }
12137
12138        fn set_bytes(&self, path: &str, body: Vec<u8>) {
12139            self.set_response(
12140                path,
12141                TestHttpResponse {
12142                    status: 200,
12143                    body,
12144                    source_version: None,
12145                },
12146            );
12147        }
12148
12149        fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
12150            self.set_response(
12151                "/source.msix",
12152                TestHttpResponse {
12153                    status: 200,
12154                    body,
12155                    source_version: Some(source_version.to_owned()),
12156                },
12157            );
12158        }
12159
12160        fn set_status(&self, path: &str, status: u16) {
12161            self.set_response(
12162                path,
12163                TestHttpResponse {
12164                    status,
12165                    body: Vec::new(),
12166                    source_version: None,
12167                },
12168            );
12169        }
12170
12171        fn set_response(&self, path: &str, response: TestHttpResponse) {
12172            self.routes
12173                .lock()
12174                .expect("test HTTP routes")
12175                .insert(path.to_owned(), response);
12176        }
12177
12178        fn request_count(&self, path: &str) -> usize {
12179            self.counts
12180                .lock()
12181                .expect("test HTTP counts")
12182                .get(path)
12183                .copied()
12184                .unwrap_or_default()
12185        }
12186    }
12187
12188    impl Drop for TestHttpServer {
12189        fn drop(&mut self) {
12190            self.running.store(false, AtomicOrdering::SeqCst);
12191            let _ = TcpStream::connect(self.addr);
12192            if let Some(handle) = self.handle.take() {
12193                let _ = handle.join();
12194            }
12195        }
12196    }
12197
12198    fn handle_test_http_request(
12199        mut stream: TcpStream,
12200        routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12201        counts: &Arc<Mutex<HashMap<String, usize>>>,
12202    ) {
12203        let mut buffer = [0u8; 2048];
12204        let read = stream.read(&mut buffer).unwrap_or(0);
12205        let request = String::from_utf8_lossy(&buffer[..read]);
12206        let path = request
12207            .lines()
12208            .next()
12209            .and_then(|line| line.split_whitespace().nth(1))
12210            .unwrap_or("/");
12211        *counts
12212            .lock()
12213            .expect("test HTTP counts")
12214            .entry(path.to_owned())
12215            .or_default() += 1;
12216        let response = routes.lock().expect("test HTTP routes").get(path).cloned();
12217        let response = response.unwrap_or(TestHttpResponse {
12218            status: 404,
12219            body: Vec::new(),
12220            source_version: None,
12221        });
12222        let reason = if response.status == 200 { "OK" } else { "Not Found" };
12223        let mut headers = format!(
12224            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
12225            response.status,
12226            reason,
12227            response.body.len()
12228        );
12229        if let Some(source_version) = response.source_version {
12230            headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
12231        }
12232        headers.push_str("\r\n");
12233        stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
12234        stream.write_all(&response.body).expect("write test HTTP body");
12235    }
12236
12237    fn test_source_record(source_url: String) -> SourceRecord {
12238        SourceRecord {
12239            name: "test-winget".to_owned(),
12240            kind: SourceKind::PreIndexed,
12241            arg: source_url,
12242            identifier: "Test.Source".to_owned(),
12243            trust_level: "Trusted".to_owned(),
12244            explicit: false,
12245            priority: 0,
12246            last_update: Some(Utc::now()),
12247            source_version: None,
12248        }
12249    }
12250
12251    fn open_test_preindexed_repository(
12252        app_root: &Path,
12253        source: &SourceRecord,
12254        interval: Option<Duration>,
12255    ) -> Repository {
12256        fs::create_dir_all(app_root).expect("create app root");
12257        save_store(
12258            app_root,
12259            &SourceStore {
12260                sources: vec![source.clone()],
12261            },
12262        )
12263        .expect("save source store");
12264        Repository::open_with_options(
12265            RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
12266        )
12267        .expect("open repository")
12268    }
12269
12270    fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
12271        let index_path = preindexed_index_path(app_root, source);
12272        if let Some(parent) = index_path.parent() {
12273            fs::create_dir_all(parent).expect("create source state directory");
12274        }
12275        fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
12276    }
12277
12278    #[test]
12279    fn replace_file_failure_keeps_existing_target() {
12280        let root = temp_app_root("replace_file_failure");
12281        fs::create_dir_all(&root).expect("create replace root");
12282        let target = root.join("index.db");
12283        let missing_source = root.join("missing.tmp");
12284        fs::write(&target, b"old-index").expect("write existing target");
12285
12286        let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
12287
12288        assert!(format!("{error:#}").contains("failed to replace"));
12289        assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
12290        let _ = fs::remove_dir_all(root);
12291    }
12292
12293    fn make_source_package(versions: &[&str]) -> Vec<u8> {
12294        let cursor = Cursor::new(Vec::new());
12295        let mut archive = zip::ZipWriter::new(cursor);
12296        let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
12297        archive
12298            .start_file("Public/index.db", options)
12299            .expect("start index entry");
12300        archive
12301            .write_all(&make_v1_index_bytes(versions))
12302            .expect("write index entry");
12303        archive.finish().expect("finish source package").into_inner()
12304    }
12305
12306    fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
12307        let root = temp_app_root("preindexed_index_bytes");
12308        fs::create_dir_all(&root).expect("create index temp root");
12309        let db_path = root.join("index.db");
12310        let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
12311        let mut sql = String::from(
12312            "CREATE TABLE ids (id TEXT);
12313             CREATE TABLE names (name TEXT);
12314             CREATE TABLE monikers (moniker TEXT);
12315             CREATE TABLE versions (version TEXT);
12316             CREATE TABLE channels (channel TEXT);
12317             CREATE TABLE pathparts (parent INT64, pathpart TEXT);
12318             CREATE TABLE manifest (
12319                 id INT64,
12320                 name INT64,
12321                 moniker INT64,
12322                 version INT64,
12323                 channel INT64,
12324                 pathpart INT64
12325             );
12326             INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
12327             INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
12328             INSERT INTO channels(rowid, channel) VALUES (1, '');
12329             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
12330             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
12331        );
12332        for (index, version) in versions.iter().enumerate() {
12333            let rowid = index + 1;
12334            sql.push_str(&format!(
12335                "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
12336                 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
12337            ));
12338        }
12339        execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
12340        let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
12341            .expect("checkpoint preindexed V1 database");
12342        connection.cacheflush().expect("flush preindexed V1 database");
12343        drop(connection);
12344        let bytes = fs::read(&db_path).expect("read index bytes");
12345        let _ = fs::remove_dir_all(root);
12346        bytes
12347    }
12348
12349    fn test_manifest_yaml(version: &str) -> Vec<u8> {
12350        format!(
12351            "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
12352        )
12353        .into_bytes()
12354    }
12355
12356    fn show_test_package(version: Option<&str>) -> PackageQuery {
12357        PackageQuery {
12358            id: Some("Test.Package".to_owned()),
12359            source: Some("test-winget".to_owned()),
12360            exact: true,
12361            version: version.map(str::to_owned),
12362            ..PackageQuery::default()
12363        }
12364    }
12365
12366    #[test]
12367    fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
12368        let app_root = temp_app_root("preindexed_explicit_refresh");
12369        let server = TestHttpServer::start();
12370        server.set_status("/source2.msix", 404);
12371        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
12372        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12373        let source = test_source_record(server.url());
12374        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12375        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12376
12377        let result = repository
12378            .show(&show_test_package(Some("2.0.0")))
12379            .expect("show after refresh");
12380
12381        assert_eq!(result.manifest.version, "2.0.0");
12382        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
12383        let _ = fs::remove_dir_all(app_root);
12384    }
12385
12386    #[test]
12387    fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
12388        let app_root = temp_app_root("preindexed_explicit_still_missing");
12389        let server = TestHttpServer::start();
12390        server.set_status("/source2.msix", 404);
12391        server.set_source_package(make_source_package(&["1.0.0"]), "same");
12392        let source = test_source_record(server.url());
12393        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12394        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12395
12396        let error = repository
12397            .show(&show_test_package(Some("2.0.0")))
12398            .expect_err("missing explicit version must fail");
12399
12400        assert!(format!("{error:#}").contains("2.0.0"));
12401        let _ = fs::remove_dir_all(app_root);
12402    }
12403
12404    #[test]
12405    fn preindexed_stale_index_refreshes_for_latest_requests() {
12406        let app_root = temp_app_root("preindexed_ttl_refresh");
12407        let server = TestHttpServer::start();
12408        server.set_status("/source2.msix", 404);
12409        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12410        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12411        let mut source = test_source_record(server.url());
12412        source.last_update = Some(Utc::now() - Duration::days(2));
12413        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12414        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12415
12416        let result = repository
12417            .show(&show_test_package(None))
12418            .expect("show latest after TTL refresh");
12419
12420        assert_eq!(result.manifest.version, "2.0.0");
12421        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
12422        let _ = fs::remove_dir_all(app_root);
12423    }
12424
12425    #[test]
12426    fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
12427        let app_root = temp_app_root("preindexed_ttl_fallback");
12428        let server = TestHttpServer::start();
12429        server.set_status("/source2.msix", 404);
12430        server.set_status("/source.msix", 404);
12431        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
12432        let mut source = test_source_record(server.url());
12433        source.last_update = Some(Utc::now() - Duration::days(2));
12434        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12435        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12436
12437        let result = repository
12438            .show(&show_test_package(None))
12439            .expect("show latest from stale cache after refresh failure");
12440
12441        assert_eq!(result.manifest.version, "1.0.0");
12442        let _ = fs::remove_dir_all(app_root);
12443    }
12444
12445    #[test]
12446    fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
12447        let app_root = temp_app_root("preindexed_no_double_refresh");
12448        let server = TestHttpServer::start();
12449        server.set_status("/source2.msix", 404);
12450        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12451        let mut source = test_source_record(server.url());
12452        source.last_update = Some(Utc::now() - Duration::days(2));
12453        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12454        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12455
12456        let error = repository
12457            .show(&show_test_package(Some("3.0.0")))
12458            .expect_err("still-missing explicit version must fail after one stale refresh");
12459
12460        assert!(format!("{error:#}").contains("3.0.0"));
12461        assert_eq!(server.request_count("/source.msix"), 1);
12462        let _ = fs::remove_dir_all(app_root);
12463    }
12464
12465    #[test]
12466    fn select_v2_version_without_explicit_filters_returns_latest() {
12467        let versions = vec![
12468            version_entry("1.0.0", None, None),
12469            version_entry("1.2.0", None, None),
12470            version_entry("1.1.0", None, None),
12471        ];
12472
12473        let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
12474
12475        assert_eq!(selected.version, "1.2.0");
12476    }
12477
12478    #[test]
12479    fn select_v2_version_with_missing_requested_version_throws() {
12480        let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
12481
12482        let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
12483            .expect_err("missing requested version");
12484
12485        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12486        assert!(error.to_string().contains("9.9.9"));
12487    }
12488
12489    #[test]
12490    fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
12491        let versions = vec![version_entry("1.2.0", None, None)];
12492
12493        let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12494            .expect_err("unsupported channel request");
12495
12496        assert!(error.to_string().contains("beta"));
12497        assert!(error.to_string().contains("channel metadata"));
12498    }
12499
12500    #[test]
12501    fn select_v1_version_channel_only_returns_latest_matching_channel() {
12502        let versions = vec![
12503            v1_version("1.2.0", "stable"),
12504            v1_version("1.3.0", "stable"),
12505            v1_version("2.0.0", "preview"),
12506        ];
12507
12508        let selected =
12509            select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
12510
12511        assert_eq!(selected.version, "1.3.0");
12512    }
12513
12514    #[test]
12515    fn select_rest_version_channel_mismatch_throws() {
12516        let versions = vec![
12517            VersionKey {
12518                version: "1.2.0".to_owned(),
12519                channel: "stable".to_owned(),
12520            },
12521            VersionKey {
12522                version: "1.1.0".to_owned(),
12523                channel: "beta".to_owned(),
12524            },
12525        ];
12526
12527        let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12528            .expect_err("missing version and channel pair");
12529
12530        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12531    }
12532
12533    #[test]
12534    fn map_arp_version_returns_catalog_version_inside_range() {
12535        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
12536        // `10.1.826.23019`. compare_version on that against the catalog Version
12537        // `10.0.108` claims installed > available, so without this mapping the
12538        // upgrade was silently dropped.
12539        let entries = vec![
12540            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
12541            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
12542            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
12543        ];
12544
12545        assert_eq!(
12546            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
12547            Some("10.0.108")
12548        );
12549    }
12550
12551    #[test]
12552    fn map_arp_version_returns_none_when_no_range_matches() {
12553        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
12554        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
12555        // original installed version (and accept that no upgrade can be
12556        // computed) — same as winget's behavior.
12557        let entries = vec![
12558            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
12559            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
12560        ];
12561
12562        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
12563    }
12564
12565    #[test]
12566    fn map_arp_version_skips_entries_missing_arp_bounds() {
12567        // Older catalog packages predate AppsAndFeaturesEntries and have no
12568        // aMiV/aMaV. Those entries must be ignored — not silently treated as
12569        // an unbounded range.
12570        let entries = vec![
12571            version_entry("9.0.300", None, None),
12572            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
12573        ];
12574
12575        assert_eq!(
12576            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
12577            Some("9.0.117")
12578        );
12579        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
12580    }
12581
12582    #[test]
12583    fn map_arp_version_handles_inclusive_range_endpoints() {
12584        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
12585        // must still map.
12586        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
12587
12588        assert_eq!(
12589            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
12590            Some("3.14.5")
12591        );
12592        assert_eq!(
12593            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
12594            Some("3.14.5")
12595        );
12596        assert_eq!(
12597            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
12598            Some("3.14.5")
12599        );
12600    }
12601
12602    #[test]
12603    fn map_arp_version_ignores_unknown_marker() {
12604        // collect_installed_packages writes the literal "Unknown" when ARP has
12605        // no DisplayVersion. Trying to map that would falsely match the first
12606        // entry whose aMiV..aMaV string-compares to include "Unknown".
12607        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
12608
12609        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
12610        assert!(map_arp_version_to_catalog(&entries, "").is_none());
12611    }
12612
12613    #[test]
12614    fn latest_arp_anchored_skips_internal_versions() {
12615        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
12616        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
12617        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
12618        // it against an installed canonical of `1.8.6` would falsely report
12619        // an upgrade. Returns the highest anchored Version instead.
12620        let entries = vec![
12621            version_entry("8000.836.2153.0", None, None),
12622            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
12623            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
12624            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
12625        ];
12626
12627        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
12628    }
12629
12630    #[test]
12631    fn latest_arp_anchored_returns_none_without_bounds() {
12632        // App Installer's versionData has no aMiV/aMaV — every version is a
12633        // user-facing MSIX Version. Caller must fall back to
12634        // `packages.latest_version` in this case.
12635        let entries = vec![
12636            version_entry("1.28.240.0", None, None),
12637            version_entry("1.27.470.0", None, None),
12638        ];
12639
12640        assert!(latest_arp_anchored_version(&entries).is_none());
12641    }
12642
12643    #[test]
12644    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
12645        let package = InstalledPackage {
12646            name: "AzCopy v10".to_owned(),
12647            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
12648            installed_version: "10.32.2".to_owned(),
12649            publisher: Some("Microsoft".to_owned()),
12650            scope: Some("Machine".to_owned()),
12651            installer_category: Some("exe".to_owned()),
12652            install_location: None,
12653            package_family_names: Vec::new(),
12654            product_codes: Vec::new(),
12655            upgrade_codes: Vec::new(),
12656            correlated: Some(SearchMatch {
12657                source_name: "winget".to_owned(),
12658                source_kind: SourceKind::PreIndexed,
12659                id: "Microsoft.Azure.AZCopy.10".to_owned(),
12660                name: "AzCopy v10".to_owned(),
12661                moniker: None,
12662                version: Some("10.32.3".to_owned()),
12663                channel: None,
12664                match_criteria: None,
12665            }),
12666            installed_version_canonical: false,
12667            correlated_requires_explicit_upgrade: false,
12668            correlated_lacks_compatible_installer: false,
12669        };
12670
12671        let item = list_match_from_installed(package);
12672
12673        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
12674        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
12675        assert_eq!(item.source_name.as_deref(), Some("winget"));
12676        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
12677    }
12678
12679    #[test]
12680    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
12681        let package = InstalledPackage {
12682            name: "Atlassian CLI".to_owned(),
12683            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
12684            installed_version: "1.3.18-stable".to_owned(),
12685            publisher: Some("Atlassian".to_owned()),
12686            scope: Some("User".to_owned()),
12687            installer_category: Some("exe".to_owned()),
12688            install_location: None,
12689            package_family_names: Vec::new(),
12690            product_codes: Vec::new(),
12691            upgrade_codes: Vec::new(),
12692            correlated: Some(SearchMatch {
12693                source_name: "winget".to_owned(),
12694                source_kind: SourceKind::PreIndexed,
12695                id: "Atlassian.AtlassianCLI".to_owned(),
12696                name: "Atlassian CLI".to_owned(),
12697                moniker: None,
12698                version: Some("1.3.18-stable".to_owned()),
12699                channel: None,
12700                match_criteria: Some("ProductCode".to_owned()),
12701            }),
12702            installed_version_canonical: false,
12703            correlated_requires_explicit_upgrade: false,
12704            correlated_lacks_compatible_installer: false,
12705        };
12706
12707        let item = list_match_from_installed(package);
12708
12709        assert_eq!(item.id, "Atlassian.AtlassianCLI");
12710        assert_eq!(
12711            item.local_id,
12712            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
12713        );
12714        assert_ne!(item.id, item.local_id);
12715        assert_eq!(item.source_name.as_deref(), Some("winget"));
12716    }
12717
12718    #[test]
12719    fn list_response_serialization_keeps_pascal_case_fields() {
12720        let response = ListResponse {
12721            matches: vec![ListMatch {
12722                name: "PowerToys".to_owned(),
12723                id: "Microsoft.PowerToys".to_owned(),
12724                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
12725                installed_version: "0.98.1".to_owned(),
12726                available_version: Some("0.99.0".to_owned()),
12727                source_name: Some("winget".to_owned()),
12728                publisher: Some("Microsoft".to_owned()),
12729                scope: Some("Machine".to_owned()),
12730                installer_category: Some("msi".to_owned()),
12731                install_location: None,
12732                package_family_names: Vec::new(),
12733                product_codes: Vec::new(),
12734                upgrade_codes: Vec::new(),
12735            }],
12736            warnings: Vec::new(),
12737            truncated: false,
12738        };
12739
12740        let value = serde_json::to_value(&response).expect("serialize list response");
12741        let match_value = &value["Matches"][0];
12742
12743        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
12744        assert_eq!(match_value["InstalledVersion"], "0.98.1");
12745        assert_eq!(match_value["AvailableVersion"], "0.99.0");
12746        assert_eq!(match_value["SourceName"], "winget");
12747        assert!(match_value.get("local_id").is_none());
12748        assert!(match_value.get("installed_version").is_none());
12749        assert!(match_value.get("available_version").is_none());
12750        assert!(match_value.get("source_name").is_none());
12751    }
12752
12753    #[test]
12754    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
12755        let response = SearchResponse {
12756            matches: vec![SearchMatch {
12757                source_name: "msstore".to_owned(),
12758                source_kind: SourceKind::Rest,
12759                id: "9WZDNCRFJBMP".to_owned(),
12760                name: "Microsoft To Do".to_owned(),
12761                moniker: None,
12762                version: Some("2.123.456.0".to_owned()),
12763                channel: None,
12764                match_criteria: None,
12765            }],
12766            warnings: Vec::new(),
12767            truncated: false,
12768        };
12769
12770        let value = serde_json::to_value(&response).expect("serialize search response");
12771        let match_value = &value["Matches"][0];
12772
12773        assert_eq!(match_value["Name"], "Microsoft To Do");
12774        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
12775        assert_eq!(match_value["Version"], "2.123.456.0");
12776        assert_eq!(match_value["SourceName"], "msstore");
12777        assert!(match_value.get("source_name").is_none());
12778    }
12779
12780    #[test]
12781    fn versions_result_serialization_keeps_pascal_case_fields() {
12782        let result = VersionsResult {
12783            package: SearchMatch {
12784                source_name: "winget".to_owned(),
12785                source_kind: SourceKind::PreIndexed,
12786                id: "Microsoft.PowerToys".to_owned(),
12787                name: "PowerToys".to_owned(),
12788                moniker: None,
12789                version: Some("0.99.0".to_owned()),
12790                channel: None,
12791                match_criteria: None,
12792            },
12793            versions: vec![
12794                VersionKey {
12795                    version: "0.99.0".to_owned(),
12796                    channel: "stable".to_owned(),
12797                },
12798                VersionKey {
12799                    version: "0.100.0-preview".to_owned(),
12800                    channel: "preview".to_owned(),
12801                },
12802            ],
12803            warnings: Vec::new(),
12804        };
12805
12806        let value = serde_json::to_value(&result).expect("serialize versions result");
12807
12808        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
12809        assert_eq!(value["Versions"][0]["Channel"], "stable");
12810        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
12811        assert_eq!(value["Versions"][1]["Channel"], "preview");
12812        assert!(value.get("versions").is_none());
12813    }
12814
12815    #[test]
12816    fn version_data_parses_arp_bounds_from_winget_payload() {
12817        // Sanity check that aMiV/aMaV deserialize from a real-world
12818        // versionData.mszyml payload shape. Without `default`, packages
12819        // predating AppsAndFeaturesEntries would fail to parse.
12820        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";
12821
12822        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
12823        assert_eq!(document.versions.len(), 2);
12824        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
12825        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
12826        assert!(document.versions[1].arp_min_version.is_none());
12827        assert!(document.versions[1].arp_max_version.is_none());
12828    }
12829
12830    #[test]
12831    fn msix_resource_string_name_resolves_to_catalog_name() {
12832        // App Installer's MSIX manifest stores DisplayName as
12833        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
12834        // know the catalog calls it `App Installer` — show that instead of
12835        // the unresolved placeholder, matching winget's output.
12836        let mut package = InstalledPackage {
12837            name: "ms-resource:appDisplayName".to_owned(),
12838            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
12839            installed_version: "1.28.239.0".to_owned(),
12840            publisher: None,
12841            scope: Some("User".to_owned()),
12842            installer_category: Some("msix".to_owned()),
12843            install_location: None,
12844            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
12845            product_codes: Vec::new(),
12846            upgrade_codes: Vec::new(),
12847            correlated: Some(SearchMatch {
12848                source_name: "winget".to_owned(),
12849                source_kind: SourceKind::PreIndexed,
12850                id: "Microsoft.AppInstaller".to_owned(),
12851                name: "App Installer".to_owned(),
12852                moniker: None,
12853                version: Some("1.28.240.0".to_owned()),
12854                channel: None,
12855                match_criteria: Some("PackageFamilyName".to_owned()),
12856            }),
12857            installed_version_canonical: false,
12858            correlated_requires_explicit_upgrade: false,
12859            correlated_lacks_compatible_installer: false,
12860        };
12861        apply_msix_resource_string_name_fix(&mut package);
12862        assert_eq!(package.name, "App Installer");
12863    }
12864
12865    #[test]
12866    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
12867        // ARP rows can also carry unusual display names; the fix is gated
12868        // on the local_id prefix so a `ms-resource:` literal that somehow
12869        // appears in an ARP DisplayName (extremely unlikely, but possible)
12870        // doesn't get silently rewritten.
12871        let mut package = InstalledPackage {
12872            name: "ms-resource:appDisplayName".to_owned(),
12873            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
12874            installed_version: "1.0".to_owned(),
12875            publisher: None,
12876            scope: Some("Machine".to_owned()),
12877            installer_category: Some("msi".to_owned()),
12878            install_location: None,
12879            package_family_names: Vec::new(),
12880            product_codes: Vec::new(),
12881            upgrade_codes: Vec::new(),
12882            correlated: Some(SearchMatch {
12883                source_name: "winget".to_owned(),
12884                source_kind: SourceKind::PreIndexed,
12885                id: "Some.Package".to_owned(),
12886                name: "Should Not Apply".to_owned(),
12887                moniker: None,
12888                version: Some("1.0".to_owned()),
12889                channel: None,
12890                match_criteria: None,
12891            }),
12892            installed_version_canonical: false,
12893            correlated_requires_explicit_upgrade: false,
12894            correlated_lacks_compatible_installer: false,
12895        };
12896        apply_msix_resource_string_name_fix(&mut package);
12897        assert_eq!(package.name, "ms-resource:appDisplayName");
12898    }
12899
12900    #[test]
12901    fn msix_resource_string_fix_skips_resolved_names() {
12902        // The vast majority of MSIX entries have already-resolved names
12903        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
12904        // those — `meta.name` and the installed name may legitimately
12905        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
12906        let mut package = InstalledPackage {
12907            name: "Microsoft Teams".to_owned(),
12908            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
12909            installed_version: "25290.205.4069.4894".to_owned(),
12910            publisher: None,
12911            scope: Some("User".to_owned()),
12912            installer_category: Some("msix".to_owned()),
12913            install_location: None,
12914            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
12915            product_codes: Vec::new(),
12916            upgrade_codes: Vec::new(),
12917            correlated: Some(SearchMatch {
12918                source_name: "winget".to_owned(),
12919                source_kind: SourceKind::PreIndexed,
12920                id: "Microsoft.Teams".to_owned(),
12921                name: "Microsoft Teams Catalog Name".to_owned(),
12922                moniker: None,
12923                version: Some("26106.1906.4665.7308".to_owned()),
12924                channel: None,
12925                match_criteria: Some("PackageFamilyName".to_owned()),
12926            }),
12927            installed_version_canonical: false,
12928            correlated_requires_explicit_upgrade: false,
12929            correlated_lacks_compatible_installer: false,
12930        };
12931        apply_msix_resource_string_name_fix(&mut package);
12932        assert_eq!(package.name, "Microsoft Teams");
12933    }
12934
12935    #[cfg(windows)]
12936    #[test]
12937    fn unflip_packed_guid_reverses_msi_installer_packing() {
12938        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
12939        // chunks (8/4/4/2*8). This is the encoding used inside
12940        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
12941        // and their value names. Verified against the user's installed
12942        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
12943        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
12944        assert_eq!(
12945            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
12946            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
12947        );
12948        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
12949        // form `A3A70C74FE2431248AD5F8A59570C782`.
12950        assert_eq!(
12951            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
12952            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
12953        );
12954        // Reject non-32-char or non-hex inputs.
12955        assert!(unflip_packed_guid("nothex").is_none());
12956        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
12957        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
12958    }
12959
12960    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
12961        InstalledPackage {
12962            name: format!("{id} install"),
12963            local_id: format!(r"ARP\Machine\X64\{id}"),
12964            installed_version: installed_version.to_owned(),
12965            publisher: None,
12966            scope: Some("Machine".to_owned()),
12967            installer_category: Some("msi".to_owned()),
12968            install_location: None,
12969            package_family_names: Vec::new(),
12970            product_codes: Vec::new(),
12971            upgrade_codes: Vec::new(),
12972            correlated: Some(SearchMatch {
12973                source_name: "winget".to_owned(),
12974                source_kind: SourceKind::PreIndexed,
12975                id: id.to_owned(),
12976                name: id.to_owned(),
12977                moniker: None,
12978                version: Some("99.0.0".to_owned()),
12979                channel: None,
12980                match_criteria: None,
12981            }),
12982            installed_version_canonical: canonical,
12983            correlated_requires_explicit_upgrade: false,
12984            correlated_lacks_compatible_installer: false,
12985        }
12986    }
12987
12988    #[test]
12989    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
12990        // Two installed entries correlate to the same catalog id. One had its
12991        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
12992        // the other did not (e.g. VS-installed .NET SDK whose ARP
12993        // `40.10.18029` doesn't fit any bucket). Without the canonical
12994        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
12995        // wrong row wins and the upgrade silently disappears.
12996        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
12997        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
12998
12999        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
13000        assert_eq!(result.len(), 1);
13001        assert_eq!(result[0].installed_version, "10.0.108");
13002        assert!(result[0].installed_version_canonical);
13003    }
13004
13005    #[test]
13006    fn dedupe_keeps_highest_among_canonical_rows() {
13007        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
13008        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
13009
13010        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
13011        assert_eq!(result.len(), 1);
13012        assert_eq!(result[0].installed_version, "1.7.9");
13013    }
13014
13015    #[test]
13016    fn dedupe_leaves_uncorrelated_rows_alone() {
13017        // Uncorrelated entries don't reach upgrade output anyway (the
13018        // upgrade filter requires correlation), but the dedupe pass must not
13019        // drop them — `pinget list` could share this code path in the future.
13020        let uncorrelated = InstalledPackage {
13021            name: "Foo".to_owned(),
13022            local_id: r"ARP\Machine\X64\Foo".to_owned(),
13023            installed_version: "1.0".to_owned(),
13024            publisher: None,
13025            scope: Some("Machine".to_owned()),
13026            installer_category: Some("exe".to_owned()),
13027            install_location: None,
13028            package_family_names: Vec::new(),
13029            product_codes: Vec::new(),
13030            upgrade_codes: Vec::new(),
13031            correlated: None,
13032            installed_version_canonical: false,
13033            correlated_requires_explicit_upgrade: false,
13034            correlated_lacks_compatible_installer: false,
13035        };
13036        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
13037        assert_eq!(result.len(), 1);
13038    }
13039}