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                    // The new index.db is a fresh database; any WAL/SHM sidecars
2897                    // left from the previous one no longer match it and would make
2898                    // SQLite recover against the wrong database. Drop them so the
2899                    // next open starts clean.
2900                    remove_sqlite_sidecars(&index_path);
2901                    source.last_update = Some(Utc::now());
2902                    source.source_version = header_version;
2903                    return Ok(format!("downloaded {}", candidate));
2904                }
2905                Ok(response) => {
2906                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
2907                }
2908                Err(error) => {
2909                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
2910                }
2911            }
2912        }
2913
2914        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
2915    }
2916
2917    fn update_rest(&mut self, source_index: usize) -> Result<String> {
2918        let info = self.fetch_rest_information(source_index, true)?;
2919        let source = &mut self.store.sources[source_index];
2920        if !info.source_identifier.is_empty() {
2921            source.identifier = info.source_identifier.clone();
2922        }
2923        source.last_update = Some(Utc::now());
2924        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
2925        Ok("refreshed information cache".to_owned())
2926    }
2927
2928    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
2929        let source = self.source_clone(source_index);
2930        let cache_path = rest_information_cache_path(&self.app_root, &source);
2931
2932        if cache_path.exists() {
2933            let cache = serde_json::from_slice::<RestInfoCache>(
2934                &fs::read(&cache_path).context("failed to read REST information cache")?,
2935            )
2936            .context("failed to parse REST information cache")?;
2937            if cache.expires_at > Utc::now() {
2938                return Ok(cache.value);
2939            }
2940        }
2941
2942        self.fetch_rest_information(source_index, true)
2943    }
2944
2945    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
2946        let source = self.source_clone(source_index);
2947        let url = format!("{}/information", source.arg.trim_end_matches('/'));
2948        let response = self
2949            .client
2950            .get(url)
2951            .send()
2952            .context("REST information request failed")?
2953            .error_for_status()
2954            .context("REST information request returned an error")?;
2955
2956        let max_age = cache_control_max_age(&response);
2957        let json = response
2958            .json::<JsonValue>()
2959            .context("failed to parse REST information response")?;
2960        let data = json
2961            .get("Data")
2962            .cloned()
2963            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
2964        let info = serde_json::from_value::<RestInformation>(data)
2965            .context("failed to deserialize REST information payload")?;
2966
2967        if persist {
2968            let cache = RestInfoCache {
2969                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
2970                value: info.clone(),
2971            };
2972            write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
2973        }
2974
2975        Ok(info)
2976    }
2977
2978    fn load_v2_version_data(
2979        &self,
2980        source: &SourceRecord,
2981        package_rowid: i64,
2982        package_hash: &str,
2983    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
2984        let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
2985            .context("failed to reopen preindexed index for V2 version data")?;
2986        let package_hash = package_hash.to_ascii_lowercase();
2987        let package_id = query_optional_value(
2988            &connection,
2989            "SELECT id FROM packages WHERE rowid = ?1",
2990            vec![SqlValue::Integer(package_rowid)],
2991            |row| row_string(row, 0),
2992        )?
2993        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
2994        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
2995        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
2996        let yaml = decompress_mszyml(&file.bytes)?;
2997        let document =
2998            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
2999        Ok((document.versions, file.path))
3000    }
3001
3002    fn get_cached_source_file(
3003        &self,
3004        bucket: &str,
3005        source: &SourceRecord,
3006        relative_path: &str,
3007        expected_hash: Option<&str>,
3008    ) -> Result<CachedBytes> {
3009        let normalized_relative = relative_path.replace('\\', "/");
3010        let cache_path =
3011            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3012
3013        if cache_path.exists() {
3014            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3015            if hash_matches(expected_hash, &cached) {
3016                return Ok(CachedBytes {
3017                    path: cache_path,
3018                    bytes: cached,
3019                });
3020            }
3021        }
3022
3023        let url = format!(
3024            "{}/{}",
3025            source.arg.trim_end_matches('/'),
3026            normalized_relative.trim_start_matches('/')
3027        );
3028        let response = self
3029            .client
3030            .get(url)
3031            .send()
3032            .context("failed to fetch source file")?
3033            .error_for_status()
3034            .context("source file request returned an error")?;
3035        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3036
3037        if let Some(hash) = expected_hash {
3038            verify_hash(hash, &bytes)?;
3039        }
3040
3041        if let Some(parent) = cache_path.parent() {
3042            fs::create_dir_all(parent).context("failed to create cache directory")?;
3043        }
3044        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3045
3046        Ok(CachedBytes {
3047            path: cache_path,
3048            bytes,
3049        })
3050    }
3051
3052    fn get_or_fetch_rest_manifest(
3053        &mut self,
3054        source: &SourceRecord,
3055        package_id: &str,
3056        version: &str,
3057        channel: &str,
3058    ) -> Result<(Vec<u8>, PathBuf)> {
3059        let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
3060        if cache_path.exists() {
3061            return Ok((
3062                fs::read(&cache_path).context("failed to read cached REST manifest")?,
3063                cache_path,
3064            ));
3065        }
3066
3067        let info = self.load_rest_information_by_name(&source.name)?;
3068        let contract = choose_contract(&info.server_supported_versions)
3069            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3070        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3071
3072        let mut params = vec![("Version", version.to_owned())];
3073        if !channel.is_empty() {
3074            params.push(("Channel", channel.to_owned()));
3075        }
3076        if info
3077            .required_query_parameters
3078            .iter()
3079            .any(|value| value.eq_ignore_ascii_case("Market"))
3080        {
3081            params.push(("Market", default_market()));
3082        }
3083
3084        let response = self
3085            .client
3086            .get(url)
3087            .header("Version", contract)
3088            .query(&params)
3089            .send()
3090            .context("REST manifest request failed")?
3091            .error_for_status()
3092            .context("REST manifest request returned an error")?;
3093        let bytes = response
3094            .bytes()
3095            .context("failed to read REST manifest response")?
3096            .to_vec();
3097
3098        if let Some(parent) = cache_path.parent() {
3099            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3100        }
3101        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3102
3103        Ok((bytes, cache_path))
3104    }
3105
3106    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3107        let index = self
3108            .store
3109            .sources
3110            .iter()
3111            .position(|source| source.name.eq_ignore_ascii_case(name))
3112            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3113        self.load_rest_information(index)
3114    }
3115
3116    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3117        if let Some(name) = source_name {
3118            let index = self
3119                .store
3120                .sources
3121                .iter()
3122                .position(|source| source.name.eq_ignore_ascii_case(name))
3123                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3124            return Ok(vec![index]);
3125        }
3126
3127        let mut indexes: Vec<_> = self
3128            .store
3129            .sources
3130            .iter()
3131            .enumerate()
3132            .filter(|(_, source)| !source.explicit)
3133            .collect();
3134        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3135            right_source
3136                .priority
3137                .cmp(&left_source.priority)
3138                .then_with(|| left_index.cmp(right_index))
3139        });
3140        Ok(indexes.into_iter().map(|(index, _)| index).collect())
3141    }
3142
3143    fn source_clone(&self, index: usize) -> SourceRecord {
3144        self.store.sources[index].clone()
3145    }
3146
3147    fn refresh_system_winget_sources(&mut self) -> Result<()> {
3148        if self.use_system_winget_sources {
3149            self.store = load_system_winget_source_store()?;
3150        }
3151        Ok(())
3152    }
3153
3154    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3155        self.refresh_system_winget_sources()?;
3156        let selected_names: Vec<_> = if source_name.is_none() {
3157            self.store.sources.iter().map(|source| source.name.clone()).collect()
3158        } else {
3159            self.resolve_source_indexes(source_name)?
3160                .into_iter()
3161                .map(|index| self.store.sources[index].name.clone())
3162                .collect()
3163        };
3164
3165        let detail = system_winget_update_sources(source_name)?;
3166        self.refresh_system_winget_sources()?;
3167
3168        Ok(selected_names
3169            .into_iter()
3170            .filter_map(|name| {
3171                self.store
3172                    .sources
3173                    .iter()
3174                    .find(|source| source.name.eq_ignore_ascii_case(&name))
3175                    .map(|source| SourceUpdateResult {
3176                        name: source.name.clone(),
3177                        kind: source.kind,
3178                        detail: detail.clone(),
3179                    })
3180            })
3181            .collect())
3182    }
3183}
3184
3185#[derive(Debug)]
3186struct CachedBytes {
3187    path: PathBuf,
3188    bytes: Vec<u8>,
3189}
3190
3191#[derive(Debug, Clone)]
3192struct V1SearchRow {
3193    manifest_rowid: i64,
3194    package_rowid: i64,
3195    version: String,
3196    channel: String,
3197    id: String,
3198    name: String,
3199    moniker: Option<String>,
3200    match_criteria: Option<String>,
3201}
3202
3203#[derive(Debug, Clone)]
3204struct V1VersionRow {
3205    version: String,
3206    channel: String,
3207    pathpart_id: i64,
3208    manifest_hash: Option<String>,
3209}
3210
3211#[derive(Debug, Clone)]
3212struct V2SearchRow {
3213    package_rowid: i64,
3214    package_hash: String,
3215    id: String,
3216    name: String,
3217    moniker: Option<String>,
3218    version: String,
3219    match_criteria: Option<String>,
3220}
3221
3222fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3223    query.query.is_some()
3224        || query.id.is_some()
3225        || query.name.is_some()
3226        || query.moniker.is_some()
3227        || query.tag.is_some()
3228        || query.command.is_some()
3229        || query.source.is_some()
3230}
3231
3232fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3233    PackageQuery {
3234        query: query.query.clone(),
3235        id: query.id.clone(),
3236        name: query.name.clone(),
3237        moniker: query.moniker.clone(),
3238        tag: query.tag.clone(),
3239        command: query.command.clone(),
3240        source: query.source.clone(),
3241        count: Some(LIST_LOOKUP_MAX_RESULTS),
3242        exact: query.exact,
3243        version: None,
3244        channel: None,
3245        locale: None,
3246        installer_type: None,
3247        installer_architecture: None,
3248        platform: None,
3249        os_version: None,
3250        install_scope: query.install_scope.clone(),
3251    }
3252}
3253
3254fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3255    query.query.is_some() || query.id.is_some() || query.name.is_some()
3256}
3257
3258fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3259    package
3260        .correlated
3261        .as_ref()
3262        .and_then(|candidate| candidate.version.as_deref())
3263        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3264}
3265
3266fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3267    package.installed_version.eq_ignore_ascii_case("Unknown")
3268}
3269
3270fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3271    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
3272    // Discord). Explicit `--id` filters surface them regardless.
3273    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3274        return false;
3275    }
3276    // Hide rows whose latest catalog version has no installer the host can
3277    // run — winget refuses to upgrade past an unusable architecture.
3278    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3279        return false;
3280    }
3281    installed_package_has_upgrade(package)
3282        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3283}
3284
3285/// Collapses installed entries that correlate to the same catalog package id
3286/// (case-insensitive within a source) down to a single representative — the
3287/// one with the highest installed version. Uncorrelated entries pass through
3288/// untouched; they can't appear in `upgrade` output anyway (the upgrade
3289/// filter requires a correlation), so they're effectively a no-op here.
3290fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3291    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3292    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3293
3294    for package in packages {
3295        match package.correlated.as_ref() {
3296            Some(candidate) => {
3297                let key = (
3298                    candidate.id.to_ascii_lowercase(),
3299                    candidate.source_name.to_ascii_lowercase(),
3300                );
3301                let keep_new = match by_id.get(&key) {
3302                    Some(existing) => {
3303                        // Prefer rows whose `installed_version` was remapped to
3304                        // a catalog Version (via aMiV/aMaV). Comparing a raw
3305                        // ARP DisplayVersion like `40.10.18029` against the
3306                        // catalog's `10.0.300` is meaningless and would also
3307                        // hide the row that legitimately upgrades to it.
3308                        match (
3309                            package.installed_version_canonical,
3310                            existing.installed_version_canonical,
3311                        ) {
3312                            (true, false) => true,
3313                            (false, true) => false,
3314                            _ => {
3315                                compare_version(&package.installed_version, &existing.installed_version)
3316                                    == Ordering::Greater
3317                            }
3318                        }
3319                    }
3320                    None => true,
3321                };
3322                if keep_new {
3323                    by_id.insert(key, package);
3324                }
3325            }
3326            None => uncorrelated.push(package),
3327        }
3328    }
3329
3330    let mut result = uncorrelated;
3331    result.extend(by_id.into_values());
3332    result
3333}
3334
3335fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3336    let mut source_specific = None;
3337    let mut source_agnostic = None;
3338
3339    for pin in pins {
3340        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3341            continue;
3342        }
3343
3344        if pin.source_id.is_empty() {
3345            if source_agnostic.is_none() {
3346                source_agnostic = Some(pin);
3347            }
3348        } else if item
3349            .source_name
3350            .as_deref()
3351            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3352        {
3353            source_specific = Some(pin);
3354            break;
3355        }
3356    }
3357
3358    source_specific.or(source_agnostic)
3359}
3360
3361fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3362    let Some(available_version) = item.available_version.as_deref() else {
3363        return false;
3364    };
3365    let Some(pin) = find_applicable_pin(item, pins) else {
3366        return false;
3367    };
3368
3369    match pin.pin_type {
3370        PinType::Blocking => true,
3371        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3372    }
3373}
3374
3375fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3376    if pattern.trim().is_empty() || pattern == "*" {
3377        return true;
3378    }
3379
3380    if !pattern.contains('*') {
3381        return version.eq_ignore_ascii_case(pattern);
3382    }
3383
3384    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3385        let prefix = &pattern[..pattern.len() - 1];
3386        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3387    }
3388
3389    version.eq_ignore_ascii_case(pattern)
3390}
3391
3392fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3393    let available_version = package.correlated.as_ref().and_then(|candidate| {
3394        candidate.version.as_ref().and_then(|candidate_version| {
3395            if installed_package_has_unknown_version(&package)
3396                || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3397            {
3398                Some(candidate_version.clone())
3399            } else {
3400                None
3401            }
3402        })
3403    });
3404    let source_name = package
3405        .correlated
3406        .as_ref()
3407        .map(|candidate| candidate.source_name.clone())
3408        .filter(|value| !value.is_empty());
3409    let id = package
3410        .correlated
3411        .as_ref()
3412        .map(|candidate| candidate.id.clone())
3413        .unwrap_or_else(|| package.local_id.clone());
3414
3415    ListMatch {
3416        name: package.name,
3417        id,
3418        local_id: package.local_id,
3419        installed_version: package.installed_version,
3420        available_version,
3421        source_name,
3422        publisher: package.publisher,
3423        scope: package.scope,
3424        installer_category: package.installer_category,
3425        install_location: package.install_location,
3426        package_family_names: package.package_family_names,
3427        product_codes: package.product_codes,
3428        upgrade_codes: package.upgrade_codes,
3429    }
3430}
3431
3432fn list_sort_weight(package: &InstalledPackage) -> usize {
3433    if package.local_id.starts_with("ARP\\") {
3434        0
3435    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3436        1
3437    } else {
3438        2
3439    }
3440}
3441
3442fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3443    let correlated = package.correlated.as_ref();
3444
3445    if let Some(value) = &query.id {
3446        let local_match = matches_text(&package.local_id, value, query.exact);
3447        let correlated_match = correlated
3448            .map(|candidate| matches_text(&candidate.id, value, query.exact))
3449            .unwrap_or(false);
3450        if !local_match && !correlated_match {
3451            return false;
3452        }
3453    }
3454
3455    if let Some(value) = &query.name
3456        && !matches_text(&package.name, value, query.exact)
3457    {
3458        return false;
3459    }
3460
3461    if let Some(value) = &query.product_code
3462        && !package
3463            .product_codes
3464            .iter()
3465            .any(|code| code.eq_ignore_ascii_case(value))
3466    {
3467        return false;
3468    }
3469
3470    if let Some(value) = &query.version
3471        && !package.installed_version.eq_ignore_ascii_case(value)
3472    {
3473        return false;
3474    }
3475
3476    if let Some(value) = &query.query {
3477        let local_match =
3478            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3479        let correlated_match = correlated
3480            .map(|candidate| {
3481                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3482            })
3483            .unwrap_or(false);
3484        if !local_match && !correlated_match {
3485            return false;
3486        }
3487    }
3488
3489    if let Some(source) = &query.source
3490        && correlated
3491            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3492            .unwrap_or(true)
3493    {
3494        return false;
3495    }
3496
3497    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3498        return false;
3499    }
3500
3501    true
3502}
3503
3504fn correlate_installed_package(
3505    package: &InstalledPackage,
3506    candidates: &[SearchMatch],
3507    allow_loose_name_match: bool,
3508) -> Option<SearchMatch> {
3509    // MSIX entries can only be correlated through their PackageFamilyName.
3510    // That lookup happens earlier in `correlate_installed_via_index`; if it
3511    // didn't find a match, name-based fallback is wrong — the catalog
3512    // doesn't carry this MSIX's identity, so any name collision (e.g. the
3513    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
3514    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
3515    // doesn't make.
3516    if package.local_id.starts_with("MSIX\\") {
3517        return None;
3518    }
3519
3520    let installed_name = normalize_correlation_name(&package.name);
3521    let installed_name_lower = package.name.to_ascii_lowercase();
3522    let candidate_names = correlation_name_candidates(&package.name);
3523
3524    let mut scored: Vec<(i32, SearchMatch)> = candidates
3525        .iter()
3526        .filter_map(|candidate| {
3527            let candidate_name = normalize_correlation_name(&candidate.name);
3528            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3529                1000
3530            } else if candidate_names.iter().any(|name| {
3531                let normalized = normalize_correlation_name(name);
3532                normalized == candidate_name
3533            }) {
3534                900
3535            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3536                700
3537            } else {
3538                0
3539            };
3540
3541            if base_score == 0 {
3542                return None;
3543            }
3544
3545            // Tiebreaker so we pick the *right* candidate when multiple catalog
3546            // entries collapse to the same normalized name or all loose-match a
3547            // long installed name. Two failure modes this addresses:
3548            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
3549            //     alphanumeric filter strips `+`/`-`), so both score 900 and
3550            //     `max_by_key` arbitrarily picked the latter.
3551            //   • Loose substring match picks up `Studio` inside
3552            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
3553            //     `Microsoft.DotNet.SDK.10` when both score 700.
3554            // The bonus rewards catalog names that appear verbatim in the
3555            // installed display name — strongly when they anchor the start,
3556            // weaker when they're embedded.
3557            let candidate_lower = candidate.name.to_ascii_lowercase();
3558            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3559                100
3560            } else if installed_name_lower.contains(&candidate_lower) {
3561                50
3562            } else {
3563                0
3564            };
3565
3566            Some((base_score + prefix_bonus, candidate.clone()))
3567        })
3568        .collect();
3569
3570    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3571    let mut iter = scored.into_iter();
3572    let (top_score, top_match) = iter.next()?;
3573
3574    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
3575    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
3576    // normalize their names to `git` and tie at the same score — the only
3577    // signal that disambiguates them, publisher, isn't part of this scoring
3578    // function. winget refuses the correlation in this case (the install
3579    // shows up with an empty Source); we do the same to avoid manufacturing
3580    // a fake upgrade against the wrong package.
3581    if let Some((next_score, _)) = iter.next()
3582        && next_score == top_score
3583    {
3584        return None;
3585    }
3586    Some(top_match)
3587}
3588
3589fn correlation_name_candidates(name: &str) -> Vec<String> {
3590    let mut candidates = Vec::new();
3591    candidates.push(name.trim().to_owned());
3592
3593    let mut trimmed = name.trim().to_owned();
3594    if let Some(index) = trimmed.find(" (") {
3595        trimmed.truncate(index);
3596    }
3597
3598    let mut words = Vec::new();
3599    for token in trimmed.split_whitespace() {
3600        let lower = token
3601            .trim_matches(|ch: char| ch == '(' || ch == ')')
3602            .to_ascii_lowercase();
3603        if !words.is_empty()
3604            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3605        {
3606            break;
3607        }
3608        words.push(token);
3609    }
3610
3611    if !words.is_empty() {
3612        candidates.push(words.join(" "));
3613    }
3614
3615    candidates.sort();
3616    candidates.dedup();
3617    candidates
3618}
3619
3620fn normalize_correlation_name(value: &str) -> String {
3621    value
3622        .chars()
3623        .filter(|ch| ch.is_ascii_alphanumeric())
3624        .flat_map(|ch| ch.to_lowercase())
3625        .collect()
3626}
3627
3628struct V2PackageMetadata {
3629    rowid: i64,
3630    id: String,
3631    name: String,
3632    moniker: Option<String>,
3633    latest_version: String,
3634    package_hash: String,
3635}
3636
3637/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
3638/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
3639/// and the field that produced the match. This is winget's primary correlation
3640/// path; falling back to display-name matching is only correct for ARP entries
3641/// that lack all three identifiers.
3642fn lookup_identity_match_v2(
3643    connection: &Connection,
3644    package: &InstalledPackage,
3645) -> Result<Option<(i64, &'static str)>> {
3646    for pfn in &package.package_family_names {
3647        if let Some(rowid) = query_optional_value(
3648            connection,
3649            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3650            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3651            |row| row_i64(row, 0),
3652        )? {
3653            return Ok(Some((rowid, "PackageFamilyName")));
3654        }
3655    }
3656    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
3657    // identity — it stays stable across versions, while ProductCode changes
3658    // every release. When a single ProductCode happens to also be listed
3659    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
3660    // installer rows from earlier LTS-line ProductCodes, while the v24
3661    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
3662    // the upgrade-side match represents the user's "package family" — the
3663    // one winget uses.
3664    for code in &package.upgrade_codes {
3665        if let Some(rowid) = query_optional_value(
3666            connection,
3667            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3668            vec![SqlValue::Text(code.to_ascii_lowercase())],
3669            |row| row_i64(row, 0),
3670        )? {
3671            return Ok(Some((rowid, "UpgradeCode")));
3672        }
3673    }
3674    for code in &package.product_codes {
3675        if let Some(rowid) = query_optional_value(
3676            connection,
3677            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3678            vec![SqlValue::Text(code.to_ascii_lowercase())],
3679            |row| row_i64(row, 0),
3680        )? {
3681            return Ok(Some((rowid, "ProductCode")));
3682        }
3683    }
3684    Ok(None)
3685}
3686
3687/// Resolves a v2 package by its catalog id back to its rowid + hash so the
3688/// post-correlation enrichment pass can load versionData for rows that
3689/// correlated through the name-based fallback (which throws the rowid away).
3690fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3691    query_optional_value(
3692        connection,
3693        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3694        vec![SqlValue::Text(package_id.to_owned())],
3695        |row| {
3696            Ok(V2PackageMetadata {
3697                rowid: row_i64(row, 0)?,
3698                id: row_string(row, 1)?,
3699                name: row_string(row, 2)?,
3700                moniker: row_opt_string(row, 3)?,
3701                latest_version: row_string(row, 4)?,
3702                package_hash: row_hex_string(row, 5)?,
3703            })
3704        },
3705    )
3706}
3707
3708/// MSIX entries whose Windows resource-string display name (e.g.
3709/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
3710/// would otherwise leak that placeholder into pinget's output. winget
3711/// resolves it via `Windows.Management.Deployment` and shows the catalog's
3712/// `PackageName`; we mirror that by substituting the correlated catalog
3713/// name once the package is correlated, which is the parity-faithful
3714/// substitute we can produce without taking a WinRT dependency.
3715fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3716    if !package.local_id.starts_with("MSIX\\") {
3717        return;
3718    }
3719    if !package.name.starts_with("ms-resource:") {
3720        return;
3721    }
3722    let Some(candidate) = package.correlated.as_ref() else {
3723        return;
3724    };
3725    if !candidate.name.is_empty() {
3726        package.name = candidate.name.clone();
3727    }
3728}
3729
3730fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3731    query_optional_value(
3732        connection,
3733        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3734        vec![SqlValue::Integer(package_rowid)],
3735        |row| {
3736            Ok(V2PackageMetadata {
3737                rowid: package_rowid,
3738                id: row_string(row, 0)?,
3739                name: row_string(row, 1)?,
3740                moniker: row_opt_string(row, 2)?,
3741                latest_version: row_string(row, 3)?,
3742                package_hash: row_hex_string(row, 4)?,
3743            })
3744        },
3745    )
3746}
3747
3748fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3749    query_optional_value(
3750        connection,
3751        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3752        Vec::new(),
3753        |row| row_i64(row, 0),
3754    )
3755    .ok()
3756    .flatten()
3757    .is_some()
3758}
3759
3760/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
3761/// pair matches the installed package — if and only if the match is
3762/// unique. Multiple matches mean the normalization wasn't strong enough to
3763/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
3764/// distinct catalog packages); winget refuses to correlate in that case
3765/// and we do the same.
3766fn lookup_unique_normalized_identity(
3767    connection: &Connection,
3768    norm_name: &str,
3769    norm_publisher: &str,
3770) -> Result<Option<i64>> {
3771    let rows: Vec<i64> = query_rows(
3772        connection,
3773        "SELECT DISTINCT n.package FROM norm_names2 n \
3774         WHERE n.norm_name = ?1 \
3775         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3776         LIMIT 2",
3777        vec![
3778            SqlValue::Text(norm_name.to_owned()),
3779            SqlValue::Text(norm_publisher.to_owned()),
3780        ],
3781        |row| row_i64(row, 0),
3782    )?;
3783    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3784}
3785
3786fn v2_identity_tables_present(connection: &Connection) -> bool {
3787    query_optional_value(
3788        connection,
3789        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3790        Vec::new(),
3791        |row| row_i64(row, 0),
3792    )
3793    .ok()
3794    .flatten()
3795    .is_some()
3796}
3797
3798/// Translates an ARP DisplayVersion to the catalog Version it maps to.
3799/// For packages like the .NET SDK or Python the ARP version is an MSI build
3800/// number (e.g. `10.1.826.23019`) unrelated to the public version
3801/// (`10.0.108`). winget records the ARP range per catalog version in
3802/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
3803/// contains the installed version.
3804fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3805    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3806        return None;
3807    }
3808    for entry in entries {
3809        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3810            continue;
3811        };
3812        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3813        {
3814            return Some(entry.version.clone());
3815        }
3816    }
3817    None
3818}
3819
3820/// Returns the latest catalog Version that carries ARP-range metadata
3821/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
3822/// publish "internal" version rows whose `v` is an MSI build number
3823/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
3824/// upgrade targets — when the installed side was matched through an ARP
3825/// range, the available side must be a peer that also exposes ARP bounds so
3826/// the two versions compare on the same scale. Returns `None` when no entry
3827/// has bounds; the caller should fall back to `packages.latest_version`.
3828fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3829    entries
3830        .iter()
3831        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3832        .max_by(|a, b| compare_version(&a.version, &b.version))
3833        .map(|entry| entry.version.clone())
3834}
3835
3836#[cfg(windows)]
3837fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3838    let mut packages = Vec::new();
3839    let mut seen = BTreeSet::new();
3840
3841    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3842    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3843
3844    // MSI registers UpgradeCode → ProductCode mappings under
3845    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
3846    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
3847    // entries don't expose `UpgradeCode` directly, so winget reads it from
3848    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
3849    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
3850    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
3851    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3852
3853    if machine {
3854        collect_uninstall_view(
3855            &mut packages,
3856            &mut seen,
3857            &upgrade_codes,
3858            RegKey::predef(HKEY_LOCAL_MACHINE),
3859            "Machine",
3860            "X64",
3861            KEY_READ | KEY_WOW64_64KEY,
3862        )?;
3863        collect_uninstall_view(
3864            &mut packages,
3865            &mut seen,
3866            &upgrade_codes,
3867            RegKey::predef(HKEY_LOCAL_MACHINE),
3868            "Machine",
3869            "X86",
3870            KEY_READ | KEY_WOW64_32KEY,
3871        )?;
3872        collect_appmodel_packages(
3873            &mut packages,
3874            &mut seen,
3875            RegKey::predef(HKEY_LOCAL_MACHINE),
3876            "Machine",
3877            KEY_READ | KEY_WOW64_64KEY,
3878        )?;
3879    }
3880
3881    if user {
3882        collect_uninstall_view(
3883            &mut packages,
3884            &mut seen,
3885            &upgrade_codes,
3886            RegKey::predef(HKEY_CURRENT_USER),
3887            "User",
3888            "X64",
3889            KEY_READ,
3890        )?;
3891        collect_appmodel_packages(
3892            &mut packages,
3893            &mut seen,
3894            RegKey::predef(HKEY_CURRENT_USER),
3895            "User",
3896            KEY_READ,
3897        )?;
3898    }
3899
3900    Ok(packages)
3901}
3902
3903/// Builds a `ProductCode → UpgradeCode` map (both in standard
3904/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
3905/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
3906/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
3907/// flipped ProductCodes registered under that UpgradeCode.
3908#[cfg(windows)]
3909fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
3910    let mut map = HashMap::new();
3911    if include_machine {
3912        collect_msi_upgrade_codes_from(
3913            RegKey::predef(HKEY_LOCAL_MACHINE),
3914            r"SOFTWARE\Classes\Installer\UpgradeCodes",
3915            KEY_READ | KEY_WOW64_64KEY,
3916            &mut map,
3917        );
3918    }
3919    if include_user {
3920        collect_msi_upgrade_codes_from(
3921            RegKey::predef(HKEY_CURRENT_USER),
3922            r"Software\Microsoft\Installer\UpgradeCodes",
3923            KEY_READ,
3924            &mut map,
3925        );
3926    }
3927    map
3928}
3929
3930#[cfg(windows)]
3931fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
3932    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
3933        return;
3934    };
3935    for key_name in upgrade_codes.enum_keys().flatten() {
3936        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
3937            continue;
3938        };
3939        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
3940            continue;
3941        };
3942        for (value_name, _) in subkey.enum_values().flatten() {
3943            if let Some(product_code) = unflip_packed_guid(&value_name) {
3944                // ARP-side keys are likely to win the latest insert; first one
3945                // is fine — we just need *any* mapping. Use `entry` so the
3946                // first writer per ProductCode wins to stay deterministic.
3947                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
3948            }
3949        }
3950    }
3951}
3952
3953/// Converts the 32-character "packed GUID" used inside the MSI Installer
3954/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
3955/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
3956/// then eight 2-char byte pairs) of the GUID's hex representation.
3957#[cfg(windows)]
3958fn unflip_packed_guid(packed: &str) -> Option<String> {
3959    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
3960        return None;
3961    }
3962    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
3963    let bytes = packed.as_bytes();
3964    let mut reversed = String::with_capacity(32);
3965    let mut offset = 0;
3966    for size in CHUNKS {
3967        let chunk = &bytes[offset..offset + size];
3968        for &c in chunk.iter().rev() {
3969            reversed.push(c as char);
3970        }
3971        offset += size;
3972    }
3973    let r = reversed.to_ascii_lowercase();
3974    Some(format!(
3975        "{{{}-{}-{}-{}-{}}}",
3976        &r[0..8],
3977        &r[8..12],
3978        &r[12..16],
3979        &r[16..20],
3980        &r[20..32]
3981    ))
3982}
3983
3984#[cfg(not(windows))]
3985fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3986    Ok(Vec::new())
3987}
3988
3989#[cfg(windows)]
3990fn collect_uninstall_view(
3991    packages: &mut Vec<InstalledPackage>,
3992    seen: &mut BTreeSet<String>,
3993    upgrade_code_map: &HashMap<String, String>,
3994    root: RegKey,
3995    scope: &str,
3996    arch: &str,
3997    flags: u32,
3998) -> Result<()> {
3999    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4000
4001    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4002        Ok(key) => key,
4003        Err(_) => return Ok(()),
4004    };
4005
4006    for key_name in uninstall.enum_keys().flatten() {
4007        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4008            Ok(key) => key,
4009            Err(_) => continue,
4010        };
4011        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4012        {
4013            continue;
4014        }
4015
4016        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4017            continue;
4018        };
4019
4020        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4021        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4022        let publisher = read_reg_string(&subkey, "Publisher");
4023        let install_location = read_reg_string(&subkey, "InstallLocation");
4024        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4025            .into_iter()
4026            .collect::<Vec<_>>();
4027        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4028        if product_codes.is_empty() && looks_like_product_code(&key_name) {
4029            product_codes.push(key_name.to_ascii_lowercase());
4030        }
4031        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4032        if upgrade_codes.is_empty() {
4033            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
4034            // Installer registry hive so identity correlation can fall back to
4035            // upgradecodes2 when productcodes2 picks the wrong sibling.
4036            for code in &product_codes {
4037                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4038                    upgrade_codes.push(uc.clone());
4039                    break;
4040                }
4041            }
4042        }
4043        let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4044            .as_deref()
4045            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4046        {
4047            // Honor the WinGet ARP signal so uninstall flows through
4048            // uninstall_portable (clean dir + registry removal) instead of
4049            // delegating the WinGet portable UninstallString to a winget
4050            // subprocess that only fully cleans up entries winget itself
4051            // installed.
4052            Some("portable".to_owned())
4053        } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4054            Some("msi".to_owned())
4055        } else if key_name.starts_with("MSIX\\") {
4056            Some("msix".to_owned())
4057        } else {
4058            Some("exe".to_owned())
4059        };
4060
4061        let dedupe_key = format!(
4062            "{}|{}|{}|{}",
4063            local_id,
4064            name.to_ascii_lowercase(),
4065            installed_version.to_ascii_lowercase(),
4066            publisher.clone().unwrap_or_default().to_ascii_lowercase()
4067        );
4068        if !seen.insert(dedupe_key) {
4069            continue;
4070        }
4071
4072        packages.push(InstalledPackage {
4073            name,
4074            local_id,
4075            installed_version,
4076            publisher,
4077            scope: Some(scope.to_owned()),
4078            installer_category,
4079            install_location,
4080            package_family_names,
4081            product_codes,
4082            upgrade_codes,
4083            correlated: None,
4084            installed_version_canonical: false,
4085            correlated_requires_explicit_upgrade: false,
4086            correlated_lacks_compatible_installer: false,
4087        });
4088    }
4089
4090    Ok(())
4091}
4092
4093#[cfg(windows)]
4094fn collect_appmodel_packages(
4095    packages: &mut Vec<InstalledPackage>,
4096    seen: &mut BTreeSet<String>,
4097    root: RegKey,
4098    scope: &str,
4099    flags: u32,
4100) -> Result<()> {
4101    const APPMODEL_PACKAGES_PATH: &str =
4102        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4103
4104    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4105        Ok(key) => key,
4106        Err(_) => return Ok(()),
4107    };
4108
4109    for key_name in appmodel.enum_keys().flatten() {
4110        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4111            Ok(key) => key,
4112            Err(_) => continue,
4113        };
4114
4115        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4116            continue;
4117        };
4118        let install_location = read_reg_string(&subkey, "PackageRootFolder");
4119        if install_location.as_deref().is_some_and(is_windows_system_path) {
4120            continue;
4121        }
4122
4123        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4124            continue;
4125        };
4126
4127        let local_id = format!(r"MSIX\{key_name}");
4128        let dedupe_key = format!(
4129            "{}|{}|{}",
4130            local_id,
4131            name.to_ascii_lowercase(),
4132            metadata.version.to_ascii_lowercase()
4133        );
4134        if !seen.insert(dedupe_key) {
4135            continue;
4136        }
4137
4138        packages.push(InstalledPackage {
4139            name,
4140            local_id,
4141            installed_version: metadata.version,
4142            publisher: None,
4143            scope: Some(scope.to_owned()),
4144            installer_category: Some("msix".to_owned()),
4145            install_location,
4146            package_family_names: vec![metadata.family_name],
4147            product_codes: Vec::new(),
4148            upgrade_codes: Vec::new(),
4149            correlated: None,
4150            installed_version_canonical: false,
4151            correlated_requires_explicit_upgrade: false,
4152            correlated_lacks_compatible_installer: false,
4153        });
4154    }
4155
4156    Ok(())
4157}
4158
4159#[cfg(windows)]
4160struct ParsedMsixPackageFullName {
4161    version: String,
4162    family_name: String,
4163}
4164
4165#[cfg(windows)]
4166fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4167    let segments = value.split('_').collect::<Vec<_>>();
4168    if segments.len() < 5 {
4169        return None;
4170    }
4171
4172    let name = segments[..segments.len() - 4].join("_");
4173    if name.is_empty() {
4174        return None;
4175    }
4176
4177    let version = segments[segments.len() - 4].trim();
4178    let resource_id = segments[segments.len() - 2].trim();
4179    let publisher_id = segments[segments.len() - 1].trim();
4180    if version.is_empty() || publisher_id.is_empty() {
4181        return None;
4182    }
4183
4184    let family_name = if resource_id.is_empty() {
4185        format!("{name}_{publisher_id}")
4186    } else {
4187        format!("{name}_{resource_id}_{publisher_id}")
4188    };
4189
4190    Some(ParsedMsixPackageFullName {
4191        version: version.to_owned(),
4192        family_name,
4193    })
4194}
4195
4196#[cfg(windows)]
4197fn is_windows_system_path(path: &str) -> bool {
4198    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
4199}
4200
4201#[cfg(windows)]
4202fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
4203    key.get_value::<String, _>(value_name)
4204        .ok()
4205        .map(|value| value.trim().to_owned())
4206        .filter(|value| !value.is_empty())
4207}
4208
4209#[cfg(windows)]
4210fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
4211    key.get_value::<u32, _>(value_name).ok()
4212}
4213
4214#[cfg(windows)]
4215fn looks_like_product_code(value: &str) -> bool {
4216    value.starts_with('{') && value.ends_with('}')
4217}
4218
4219fn ensure_app_dirs(app_root: &Path) -> Result<()> {
4220    fs::create_dir_all(app_root).context("failed to create app root directory")?;
4221    if uses_packaged_layout(app_root) {
4222        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
4223        let user_sources_path = packaged_user_sources_path(app_root)?;
4224        if let Some(parent) = user_sources_path.parent() {
4225            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
4226        }
4227    } else {
4228        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
4229    }
4230    Ok(())
4231}
4232
4233fn default_app_root() -> Result<PathBuf> {
4234    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
4235        return Ok(PathBuf::from(app_root));
4236    }
4237
4238    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
4239
4240    #[cfg(windows)]
4241    {
4242        if has_current_package_identity() {
4243            return Ok(local_app_data
4244                .join("Packages")
4245                .join(PACKAGED_FAMILY_NAME)
4246                .join("LocalState"));
4247        }
4248        Ok(local_app_data.join("Devolutions").join("Pinget"))
4249    }
4250
4251    #[cfg(not(windows))]
4252    {
4253        Ok(local_app_data.join("pinget"))
4254    }
4255}
4256
4257#[cfg(windows)]
4258fn has_current_package_identity() -> bool {
4259    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
4260    // process has no AppX package identity.
4261    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
4262
4263    let mut length: u32 = 0;
4264    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
4265    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
4266    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
4267    rc != APPMODEL_ERROR_NO_PACKAGE
4268}
4269
4270fn store_path(app_root: &Path) -> PathBuf {
4271    app_root.join("sources.json")
4272}
4273
4274fn user_settings_path(app_root: &Path) -> PathBuf {
4275    if uses_packaged_layout(app_root) {
4276        app_root.join("settings.json")
4277    } else {
4278        app_root.join("user-settings.json")
4279    }
4280}
4281
4282fn admin_settings_path(app_root: &Path) -> PathBuf {
4283    app_root.join("admin-settings.json")
4284}
4285
4286fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
4287    if uses_packaged_layout(app_root) {
4288        return app_root
4289            .join(packaged_source_type(source.kind))
4290            .join(sanitize_path_segment(&source.identifier));
4291    }
4292
4293    app_root.join("sources").join(sanitize_path_segment(&source.name))
4294}
4295
4296fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4297    source_state_dir(app_root, source).join("source.msix")
4298}
4299
4300fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4301    source_state_dir(app_root, source).join("index.db")
4302}
4303
4304/// Removes the `-wal` and `-shm` sidecars that SQLite/Turso leaves next to a
4305/// database file. Used after replacing `index.db` so a stale write-ahead log
4306/// from the previous database is not replayed against the new one.
4307fn remove_sqlite_sidecars(db_path: &Path) {
4308    for suffix in ["-wal", "-shm"] {
4309        let mut sidecar = db_path.as_os_str().to_owned();
4310        sidecar.push(suffix);
4311        let _ = fs::remove_file(PathBuf::from(sidecar));
4312    }
4313}
4314
4315#[cfg(windows)]
4316const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
4317
4318#[cfg(windows)]
4319fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
4320    let source_wide = source
4321        .as_os_str()
4322        .encode_wide()
4323        .chain(std::iter::once(0))
4324        .collect::<Vec<_>>();
4325    let target_wide = target
4326        .as_os_str()
4327        .encode_wide()
4328        .chain(std::iter::once(0))
4329        .collect::<Vec<_>>();
4330    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
4331    // SAFETY: Both paths are converted to null-terminated UTF-16 buffers that
4332    // live for the duration of the call; MoveFileExW does not retain them.
4333    let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
4334    if ok == 0 {
4335        return Err(std::io::Error::last_os_error());
4336    }
4337    Ok(())
4338}
4339
4340#[cfg(windows)]
4341fn replace_file(source: &Path, target: &Path) -> Result<()> {
4342    let to_err = |error: std::io::Error| -> anyhow::Error {
4343        anyhow::Error::new(error).context(format!(
4344            "failed to replace {} with {}",
4345            target.display(),
4346            source.display()
4347        ))
4348    };
4349
4350    // Fast path: atomic replace. Succeeds whenever nothing holds the target open.
4351    let first = match move_replace_existing(source, target) {
4352        Ok(()) => return Ok(()),
4353        Err(error) => error,
4354    };
4355
4356    // A missing source or absent target is not a transient lock — fail fast
4357    // instead of spinning through the retry/stage-aside path.
4358    if !source.exists() || !target.exists() {
4359        return Err(to_err(first));
4360    }
4361
4362    // MOVEFILE_REPLACE_EXISTING is denied while another handle holds the target
4363    // open. In practice that handle is an on-access AV scan of the freshly
4364    // written file or a concurrent pinget process reading the index; both are
4365    // short-lived, so retry briefly with a small backoff before falling back.
4366    let mut last = first;
4367    for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
4368        std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
4369        match move_replace_existing(source, target) {
4370            Ok(()) => return Ok(()),
4371            Err(error) => last = error,
4372        }
4373    }
4374
4375    // Persistent holder: stage the existing index aside, then move the new file
4376    // into the freed name. Renaming the existing file succeeds even while a
4377    // reader keeps its handle, because SQLite/Turso opens with FILE_SHARE_DELETE.
4378    // The staged copy is only removed once the new file is in place, and restored
4379    // if the swap fails, so a working file is never lost.
4380    let staged = target.with_extension("old.tmp");
4381    if move_replace_existing(target, &staged).is_err() {
4382        return Err(to_err(last));
4383    }
4384    match move_replace_existing(source, target) {
4385        Ok(()) => {
4386            let _ = fs::remove_file(&staged);
4387            Ok(())
4388        }
4389        Err(error) => {
4390            let _ = move_replace_existing(&staged, target);
4391            Err(to_err(error))
4392        }
4393    }
4394}
4395
4396#[cfg(not(windows))]
4397fn replace_file(source: &Path, target: &Path) -> Result<()> {
4398    fs::rename(source, target)
4399        .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
4400}
4401
4402fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4403    source_state_dir(app_root, source).join("rest-information.json")
4404}
4405
4406fn rest_manifest_cache_path_with_root(
4407    app_root: &Path,
4408    source: &SourceRecord,
4409    package_id: &str,
4410    version: &str,
4411    channel: &str,
4412) -> PathBuf {
4413    let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
4414    let digest = sha256_hex(key.as_bytes());
4415    cache_root(app_root)
4416        .join("REST_M")
4417        .join(sanitize_path_segment(&source.identifier))
4418        .join(format!("{digest}.json"))
4419}
4420
4421fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
4422    cache_root(app_root)
4423        .join(bucket)
4424        .join(sanitize_path_segment(identifier))
4425}
4426
4427fn load_store(app_root: &Path) -> Result<SourceStore> {
4428    if uses_packaged_layout(app_root)
4429        && let Some(store) = load_packaged_store(app_root)?
4430    {
4431        return Ok(store);
4432    }
4433
4434    let path = store_path(app_root);
4435    if !path.exists() {
4436        let store = SourceStore::default();
4437        save_store(app_root, &store)?;
4438        return Ok(store);
4439    }
4440
4441    let bytes = fs::read(path).context("failed to read source store")?;
4442    serde_json::from_slice(&bytes).context("failed to parse source store")
4443}
4444
4445fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4446    if uses_system_winget_source_commands(app_root) {
4447        bail!("System WinGet source settings must be modified through winget.");
4448    }
4449
4450    if uses_packaged_layout(app_root) {
4451        return save_packaged_store(app_root, store);
4452    }
4453
4454    write_json(store_path(app_root), store)
4455}
4456
4457fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
4458    let user_sources_path = packaged_user_sources_path(app_root)?;
4459    let metadata_path = app_root.join("sources_metadata");
4460    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
4461    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
4462    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
4463}
4464
4465fn load_packaged_store_from_streams(
4466    user_sources_yaml: Option<&str>,
4467    metadata_yaml: Option<&str>,
4468) -> Result<Option<SourceStore>> {
4469    if is_secure_settings_stub(user_sources_yaml) {
4470        return load_system_winget_source_store().map(Some);
4471    }
4472
4473    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
4474}
4475
4476fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4477    let user_sources_path = packaged_user_sources_path(app_root)?;
4478    if let Some(parent) = user_sources_path.parent() {
4479        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
4480    }
4481
4482    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
4483        .context("failed to write packaged user_sources stream")?;
4484    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4485        .context("failed to write packaged source metadata")?;
4486    Ok(())
4487}
4488
4489fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4490    if !uses_packaged_layout(app_root) {
4491        return false;
4492    }
4493
4494    #[cfg(windows)]
4495    {
4496        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4497            return false;
4498        };
4499
4500        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4501        is_secure_settings_stub(user_sources_yaml.as_deref())
4502    }
4503
4504    #[cfg(not(windows))]
4505    {
4506        false
4507    }
4508}
4509
4510fn is_secure_settings_stub(value: Option<&str>) -> bool {
4511    value
4512        .map(str::trim_start)
4513        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4514}
4515
4516fn load_system_winget_source_store() -> Result<SourceStore> {
4517    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4518    Ok(SourceStore {
4519        sources: parse_system_winget_source_export(&result.stdout)?,
4520    })
4521}
4522
4523fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4524    run_checked_winget_source_command(
4525        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4526        &format!("add WinGet source '{name}'"),
4527    )?;
4528    Ok(())
4529}
4530
4531fn system_winget_remove_source(name: &str) -> Result<()> {
4532    run_checked_winget_source_command(
4533        vec![
4534            "source".to_owned(),
4535            "remove".to_owned(),
4536            "--name".to_owned(),
4537            name.to_owned(),
4538            "--disable-interactivity".to_owned(),
4539        ],
4540        &format!("remove WinGet source '{name}'"),
4541    )?;
4542    Ok(())
4543}
4544
4545fn system_winget_reset_source(name: &str) -> Result<()> {
4546    run_checked_winget_source_command(
4547        vec![
4548            "source".to_owned(),
4549            "reset".to_owned(),
4550            "--name".to_owned(),
4551            name.to_owned(),
4552            "--force".to_owned(),
4553            "--disable-interactivity".to_owned(),
4554        ],
4555        &format!("reset WinGet source '{name}'"),
4556    )?;
4557    Ok(())
4558}
4559
4560fn system_winget_reset_sources() -> Result<()> {
4561    run_checked_winget_source_command(
4562        vec![
4563            "source".to_owned(),
4564            "reset".to_owned(),
4565            "--force".to_owned(),
4566            "--disable-interactivity".to_owned(),
4567        ],
4568        "reset WinGet sources",
4569    )?;
4570    Ok(())
4571}
4572
4573fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4574    let mut args = vec!["source".to_owned(), "update".to_owned()];
4575    if let Some(name) = name {
4576        args.push("--name".to_owned());
4577        args.push(name.to_owned());
4578    }
4579
4580    args.push("--disable-interactivity".to_owned());
4581    let result = run_checked_winget_source_command(
4582        args,
4583        if name.is_some() {
4584            "update WinGet source"
4585        } else {
4586            "update WinGet sources"
4587        },
4588    )?;
4589    let detail = result.stdout.trim();
4590    Ok(if detail.is_empty() {
4591        "Done".to_owned()
4592    } else {
4593        detail.to_owned()
4594    })
4595}
4596
4597fn build_winget_source_export_arguments() -> Vec<String> {
4598    vec![
4599        "source".to_owned(),
4600        "export".to_owned(),
4601        "--disable-interactivity".to_owned(),
4602    ]
4603}
4604
4605fn build_winget_source_add_arguments(
4606    name: &str,
4607    arg: &str,
4608    kind: SourceKind,
4609    trust_level: &str,
4610    explicit: bool,
4611) -> Vec<String> {
4612    let mut args = vec![
4613        "source".to_owned(),
4614        "add".to_owned(),
4615        "--name".to_owned(),
4616        name.to_owned(),
4617        "--arg".to_owned(),
4618        arg.to_owned(),
4619        "--type".to_owned(),
4620        packaged_source_type(kind).to_owned(),
4621        "--disable-interactivity".to_owned(),
4622    ];
4623
4624    if !trust_level.trim().is_empty() {
4625        args.push("--trust-level".to_owned());
4626        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4627            "trusted".to_owned()
4628        } else {
4629            "none".to_owned()
4630        });
4631    }
4632
4633    if explicit {
4634        args.push("--explicit".to_owned());
4635    }
4636
4637    args
4638}
4639
4640#[derive(Debug)]
4641struct WingetSourceCommandResult {
4642    exit_code: i32,
4643    stdout: String,
4644    stderr: String,
4645}
4646
4647type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4648
4649static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4650    RwLock::new(run_winget_source_command_process);
4651
4652fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4653    let result = run_winget_source_command(&args)?;
4654    if result.exit_code == 0 {
4655        return Ok(result);
4656    }
4657
4658    let detail = [result.stderr.trim(), result.stdout.trim()]
4659        .into_iter()
4660        .filter(|value| !value.is_empty())
4661        .collect::<Vec<_>>()
4662        .join("\n");
4663    if detail.is_empty() {
4664        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4665    }
4666
4667    bail!(
4668        "Failed to {action}; winget exited with code {}:\n{}",
4669        result.exit_code,
4670        detail
4671    );
4672}
4673
4674fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4675    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4676        .read()
4677        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4678    runner(args)
4679}
4680
4681fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4682    use std::process::Command;
4683
4684    let output = Command::new("winget")
4685        .args(args)
4686        .output()
4687        .context("failed to run winget source command")?;
4688
4689    Ok(WingetSourceCommandResult {
4690        exit_code: output.status.code().unwrap_or(-1),
4691        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4692        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4693    })
4694}
4695
4696fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4697    let trimmed = output.trim();
4698    if trimmed.is_empty() {
4699        return Ok(Vec::new());
4700    }
4701
4702    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4703        && let Some(sources) = parse_system_winget_source_export_value(&value)?
4704    {
4705        return Ok(order_sources(sources));
4706    }
4707
4708    let mut sources = Vec::new();
4709    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4710        if line.is_empty() {
4711            continue;
4712        }
4713
4714        let value: JsonValue = serde_json::from_str(line)
4715            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4716        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4717            bail!("WinGet source export returned a non-source line: {line}");
4718        };
4719        sources.append(&mut parsed);
4720    }
4721
4722    Ok(order_sources(sources))
4723}
4724
4725fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4726    match value {
4727        JsonValue::Array(items) => items
4728            .iter()
4729            .map(parse_system_winget_source_record)
4730            .collect::<Result<Vec<_>>>()
4731            .map(Some),
4732        JsonValue::Object(object) => {
4733            if let Some(JsonValue::Array(items)) = object.get("Sources") {
4734                items
4735                    .iter()
4736                    .map(parse_system_winget_source_record)
4737                    .collect::<Result<Vec<_>>>()
4738                    .map(Some)
4739            } else if object.contains_key("Name") {
4740                Ok(Some(vec![parse_system_winget_source_record(value)?]))
4741            } else {
4742                Ok(None)
4743            }
4744        }
4745        _ => Ok(None),
4746    }
4747}
4748
4749fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4750    let object = value
4751        .as_object()
4752        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4753    let name = required_json_string(object, "Name")?;
4754    let source_type = required_json_string(object, "Type")?;
4755    let arg = required_json_string(object, "Arg")?;
4756    let kind = parse_system_winget_source_kind(&source_type)?;
4757    let identifier = optional_json_string(object, "Identifier")
4758        .or_else(|| optional_json_string(object, "Data"))
4759        .unwrap_or_else(|| name.clone());
4760
4761    Ok(SourceRecord {
4762        name,
4763        kind,
4764        arg,
4765        identifier,
4766        trust_level: parse_system_winget_trust_level(object),
4767        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4768        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4769        last_update: None,
4770        source_version: None,
4771    })
4772}
4773
4774fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4775    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4776}
4777
4778fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4779    match object.get(key)? {
4780        JsonValue::String(value) => Some(value.clone()),
4781        JsonValue::Number(value) => Some(value.to_string()),
4782        _ => None,
4783    }
4784}
4785
4786fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4787    match object.get(key)? {
4788        JsonValue::Bool(value) => Some(*value),
4789        JsonValue::String(value) => value.parse().ok(),
4790        _ => None,
4791    }
4792}
4793
4794fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4795    match object.get(key)? {
4796        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4797        JsonValue::String(value) => value.parse().ok(),
4798        _ => None,
4799    }
4800}
4801
4802fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4803    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4804        Ok(SourceKind::PreIndexed)
4805    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4806        Ok(SourceKind::Rest)
4807    } else {
4808        bail!("Unsupported WinGet source type '{value}'.")
4809    }
4810}
4811
4812fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4813    let Some(value) = object.get("TrustLevel") else {
4814        return "None".to_owned();
4815    };
4816
4817    match value {
4818        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4819        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4820        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4821        _ => "None".to_owned(),
4822    }
4823}
4824
4825fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4826    value
4827        .as_str()
4828        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4829}
4830
4831fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4832    sources.sort_by(|left, right| {
4833        left.explicit
4834            .cmp(&right.explicit)
4835            .then_with(|| right.priority.cmp(&left.priority))
4836            .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4837    });
4838    sources
4839}
4840
4841fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4842    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4843        return None;
4844    }
4845
4846    let mut sources = SourceStore::default().sources;
4847    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4848        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4849            continue;
4850        };
4851
4852        if yaml_bool(entry.get("IsTombstone")) {
4853            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4854            continue;
4855        }
4856
4857        let Some(mapped) = map_packaged_source_entry(&entry) else {
4858            continue;
4859        };
4860
4861        if let Some(existing) = sources
4862            .iter_mut()
4863            .find(|source| source.name.eq_ignore_ascii_case(&name))
4864        {
4865            *existing = mapped;
4866        } else {
4867            sources.push(mapped);
4868        }
4869    }
4870
4871    for entry in parse_packaged_yaml_entries(metadata_yaml) {
4872        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4873            continue;
4874        };
4875
4876        let Some(source) = sources
4877            .iter_mut()
4878            .find(|source| source.name.eq_ignore_ascii_case(&name))
4879        else {
4880            continue;
4881        };
4882
4883        if let Some(last_update) = entry
4884            .get("LastUpdate")
4885            .and_then(|value| value.as_deref())
4886            .and_then(parse_packaged_datetime)
4887        {
4888            source.last_update = Some(last_update);
4889        }
4890
4891        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4892            && !source_version.is_empty()
4893        {
4894            source.source_version = Some(source_version);
4895        }
4896    }
4897
4898    Some(SourceStore { sources })
4899}
4900
4901fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
4902    let Some(yaml) = yaml else {
4903        return Vec::new();
4904    };
4905
4906    let mut entries = Vec::new();
4907    let mut current: Option<BTreeMap<String, Option<String>>> = None;
4908    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
4909        let line = raw_line.trim_end();
4910        let trimmed = line.trim();
4911        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
4912            continue;
4913        }
4914
4915        if let Some(remainder) = trimmed.strip_prefix("- ") {
4916            if let Some(entry) = current.take() {
4917                entries.push(entry);
4918            }
4919
4920            let mut entry = BTreeMap::new();
4921            add_packaged_yaml_key_value(&mut entry, remainder);
4922            current = Some(entry);
4923            continue;
4924        }
4925
4926        if let Some(entry) = current.as_mut() {
4927            add_packaged_yaml_key_value(entry, trimmed);
4928        }
4929    }
4930
4931    if let Some(entry) = current {
4932        entries.push(entry);
4933    }
4934
4935    entries
4936}
4937
4938fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
4939    let Some((key, value)) = line.split_once(':') else {
4940        return;
4941    };
4942
4943    entry.insert(
4944        key.trim().to_owned(),
4945        Some(unquote_yaml_scalar(value.trim()).to_owned()),
4946    );
4947}
4948
4949fn unquote_yaml_scalar(value: &str) -> &str {
4950    if value.len() >= 2 {
4951        let bytes = value.as_bytes();
4952        let first = bytes[0] as char;
4953        let last = bytes[value.len() - 1] as char;
4954        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
4955            return &value[1..value.len() - 1];
4956        }
4957    }
4958
4959    value
4960}
4961
4962fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
4963    let name = entry.get("Name").and_then(|value| value.clone())?;
4964    let source_type = entry.get("Type").and_then(|value| value.clone())?;
4965    let arg = entry.get("Arg").and_then(|value| value.clone())?;
4966    let kind = parse_packaged_source_kind(&source_type)?;
4967
4968    Some(SourceRecord {
4969        name: name.clone(),
4970        kind,
4971        arg,
4972        identifier: entry
4973            .get("Identifier")
4974            .and_then(|value| value.clone())
4975            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
4976            .unwrap_or(name),
4977        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
4978        explicit: yaml_bool(entry.get("Explicit")),
4979        priority: entry
4980            .get("Priority")
4981            .and_then(|value| value.as_deref())
4982            .and_then(|value| value.parse::<i32>().ok())
4983            .unwrap_or_default(),
4984        last_update: None,
4985        source_version: None,
4986    })
4987}
4988
4989fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
4990    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4991        Some(SourceKind::PreIndexed)
4992    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4993        Some(SourceKind::Rest)
4994    } else {
4995        None
4996    }
4997}
4998
4999fn parse_packaged_trust_level(value: Option<&str>) -> String {
5000    match value {
5001        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5002        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5003        _ => "None".to_owned(),
5004    }
5005}
5006
5007fn yaml_bool(value: Option<&Option<String>>) -> bool {
5008    value
5009        .and_then(|raw| raw.as_deref())
5010        .and_then(|raw| raw.parse::<bool>().ok())
5011        .unwrap_or(false)
5012}
5013
5014fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5015    if let Ok(unix_seconds) = value.parse::<i64>() {
5016        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5017    }
5018
5019    DateTime::parse_from_rfc3339(value)
5020        .ok()
5021        .map(|value| value.with_timezone(&Utc))
5022}
5023
5024fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5025    let mut lines = vec!["Sources:".to_owned()];
5026    for source in &store.sources {
5027        lines.push(format!("  - Name: {}", source.name));
5028        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
5029        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
5030        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
5031        lines.push(format!(
5032            "    TrustLevel: {}",
5033            if source.trust_level.eq_ignore_ascii_case("Trusted") {
5034                1
5035            } else {
5036                0
5037            }
5038        ));
5039        lines.push(format!("    Explicit: {}", source.explicit));
5040        lines.push(format!("    Priority: {}", source.priority));
5041        lines.push("    IsTombstone: false".to_owned());
5042    }
5043    lines.push(String::new());
5044    lines.join("\n")
5045}
5046
5047fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5048    let mut lines = vec!["Sources:".to_owned()];
5049    for source in &store.sources {
5050        if source.last_update.is_none() && source.source_version.is_none() {
5051            continue;
5052        }
5053
5054        lines.push(format!("  - Name: {}", source.name));
5055        if let Some(last_update) = source.last_update {
5056            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
5057        }
5058        if let Some(source_version) = &source.source_version {
5059            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
5060        }
5061    }
5062    lines.push(String::new());
5063    lines.join("\n")
5064}
5065
5066fn yaml_scalar(value: &str) -> String {
5067    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
5068        format!("\"{}\"", value.replace('"', "\\\""))
5069    } else {
5070        value.to_owned()
5071    }
5072}
5073
5074fn uses_packaged_layout(app_root: &Path) -> bool {
5075    #[cfg(windows)]
5076    {
5077        try_get_packaged_local_state_root(app_root).is_some()
5078    }
5079
5080    #[cfg(not(windows))]
5081    {
5082        let _ = app_root;
5083        false
5084    }
5085}
5086
5087#[cfg(windows)]
5088fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
5089    let local_state = app_root.file_name()?.to_string_lossy();
5090    if !local_state.eq_ignore_ascii_case("LocalState") {
5091        return None;
5092    }
5093
5094    let family = app_root.parent()?.file_name()?.to_string_lossy();
5095    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
5096        return None;
5097    }
5098
5099    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
5100    if !packages.eq_ignore_ascii_case("Packages") {
5101        return None;
5102    }
5103
5104    Some(app_root.to_path_buf())
5105}
5106
5107#[cfg(windows)]
5108fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
5109    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
5110    Ok(packaged_secure_settings_root()?.join("user_sources"))
5111}
5112
5113#[cfg(not(windows))]
5114fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
5115    bail!("packaged WinGet source streams are only available on Windows")
5116}
5117
5118#[cfg(windows)]
5119fn packaged_secure_settings_root() -> Result<PathBuf> {
5120    let program_data = std::env::var_os("PROGRAMDATA")
5121        .map(PathBuf::from)
5122        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
5123    Ok(program_data
5124        .join("Microsoft")
5125        .join("WinGet")
5126        .join(current_user_sid()?)
5127        .join("settings")
5128        .join("pkg")
5129        .join(PACKAGED_NAME))
5130}
5131
5132#[cfg(windows)]
5133#[allow(clippy::multiple_unsafe_ops_per_block)]
5134fn current_user_sid() -> Result<String> {
5135    fn close_token(token: HANDLE) {
5136        if !token.is_null() {
5137            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
5138            unsafe {
5139                CloseHandle(token);
5140            }
5141        }
5142    }
5143
5144    let mut token: HANDLE = std::ptr::null_mut();
5145    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
5146    let current_process = unsafe { GetCurrentProcess() };
5147    // SAFETY: token points to valid storage for the process token handle on success.
5148    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
5149        bail!("failed to open current process token")
5150    }
5151
5152    let mut length = 0;
5153    // SAFETY: The null buffer call asks Windows for the required buffer length.
5154    unsafe {
5155        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
5156    }
5157
5158    let element_size = size_of::<TOKEN_USER>();
5159    let element_count = (length as usize).div_ceil(element_size);
5160    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
5161    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
5162    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
5163        close_token(token);
5164        bail!("failed to get current user token information")
5165    }
5166
5167    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
5168    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
5169    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
5170    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
5171    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
5172        close_token(token);
5173        bail!("failed to convert current user SID")
5174    }
5175
5176    let mut len = 0usize;
5177    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
5178    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
5179        len += 1;
5180    }
5181
5182    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
5183    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
5184    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
5185    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
5186    unsafe {
5187        LocalFree(sid_ptr.cast());
5188    }
5189    close_token(token);
5190    Ok(sid)
5191}
5192
5193fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
5194    app_root.join("Microsoft").join("Windows Package Manager")
5195}
5196
5197fn cache_root(app_root: &Path) -> PathBuf {
5198    if uses_packaged_layout(app_root) {
5199        packaged_file_cache_root(app_root)
5200    } else {
5201        default_cache_root_fallback()
5202    }
5203}
5204
5205fn default_cache_root_fallback() -> PathBuf {
5206    std::env::temp_dir().join("cache")
5207}
5208
5209fn packaged_source_type(kind: SourceKind) -> &'static str {
5210    match kind {
5211        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
5212        SourceKind::Rest => "Microsoft.Rest",
5213    }
5214}
5215
5216fn sanitize_path_segment(value: &str) -> String {
5217    value
5218        .chars()
5219        .map(|ch| match ch {
5220            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
5221            _ => ch,
5222        })
5223        .collect()
5224}
5225
5226fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
5227    if !path.exists() {
5228        return Ok(JsonMap::new());
5229    }
5230
5231    let bytes = fs::read(path).context("failed to read JSON file")?;
5232    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
5233    Ok(match value {
5234        JsonValue::Object(object) => object,
5235        _ => JsonMap::new(),
5236    })
5237}
5238
5239fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
5240    write_json(path, value)
5241}
5242
5243fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
5244    if let Some(parent) = path.parent() {
5245        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
5246    }
5247
5248    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
5249    fs::write(path, bytes).context("failed to write JSON file")
5250}
5251
5252fn merge_json_objects(
5253    current: &JsonMap<String, JsonValue>,
5254    update: &JsonMap<String, JsonValue>,
5255) -> JsonMap<String, JsonValue> {
5256    let mut merged = current.clone();
5257    for (key, value) in update {
5258        match (merged.get(key), value) {
5259            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
5260                merged.insert(
5261                    key.clone(),
5262                    JsonValue::Object(merge_json_objects(current_object, update_object)),
5263                );
5264            }
5265            _ => {
5266                merged.insert(key.clone(), value.clone());
5267            }
5268        }
5269    }
5270
5271    merged
5272}
5273
5274fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
5275    match expected {
5276        JsonValue::Object(expected_object) => match current {
5277            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
5278                current_object
5279                    .get(key)
5280                    .is_some_and(|current_child| json_contains(current_child, expected_child))
5281            }),
5282            _ => false,
5283        },
5284        JsonValue::Array(expected_array) => match current {
5285            JsonValue::Array(current_array) => {
5286                current_array.len() == expected_array.len()
5287                    && current_array
5288                        .iter()
5289                        .zip(expected_array.iter())
5290                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
5291            }
5292            _ => false,
5293        },
5294        _ => current == expected,
5295    }
5296}
5297
5298fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
5299    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
5300        "none" | "default" => Ok("None".to_owned()),
5301        "trusted" => Ok("Trusted".to_owned()),
5302        other => bail!("unsupported source trust level: {other}"),
5303    }
5304}
5305
5306fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
5307    SUPPORTED_ADMIN_SETTINGS
5308        .iter()
5309        .copied()
5310        .find(|setting| setting.eq_ignore_ascii_case(name))
5311        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
5312}
5313
5314fn default_source_trust_level() -> String {
5315    "None".to_owned()
5316}
5317
5318fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
5319where
5320    F: FnMut(&SqlRow) -> Result<T>,
5321{
5322    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5323        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5324    let mut result = Vec::new();
5325
5326    while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
5327        result.push(map(&row)?);
5328    }
5329
5330    Ok(result)
5331}
5332
5333fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
5334where
5335    F: FnOnce(&SqlRow) -> Result<T>,
5336{
5337    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5338        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5339
5340    match pollster::block_on(rows.next()).context("failed to read SQL row")? {
5341        Some(row) => Ok(Some(map(&row)?)),
5342        None => Ok(None),
5343    }
5344}
5345
5346fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
5347    pollster::block_on(connection.execute(sql, params_from_iter(params)))
5348        .with_context(|| format!("failed to execute SQL statement: {sql}"))
5349}
5350
5351fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
5352    pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
5353}
5354
5355fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
5356    row.get_value(index)
5357        .with_context(|| format!("failed to read SQL column {index}"))
5358}
5359
5360fn row_string(row: &SqlRow, index: usize) -> Result<String> {
5361    match row_value(row, index)? {
5362        SqlValue::Text(value) => Ok(value),
5363        SqlValue::Integer(value) => Ok(value.to_string()),
5364        SqlValue::Real(value) => Ok(value.to_string()),
5365        SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
5366        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5367    }
5368}
5369
5370fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5371    match row_value(row, index)? {
5372        SqlValue::Null => Ok(None),
5373        SqlValue::Text(value) => Ok(Some(value)),
5374        SqlValue::Integer(value) => Ok(Some(value.to_string())),
5375        SqlValue::Real(value) => Ok(Some(value.to_string())),
5376        SqlValue::Blob(value) => Ok(Some(
5377            String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
5378        )),
5379    }
5380}
5381
5382fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
5383    match row_value(row, index)? {
5384        SqlValue::Integer(value) => Ok(value),
5385        SqlValue::Text(value) => value
5386            .parse::<i64>()
5387            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
5388        SqlValue::Real(value) => f64_to_i64_checked(value, index),
5389        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5390        SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
5391    }
5392}
5393
5394fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
5395    match row_value(row, index)? {
5396        SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
5397        SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
5398        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5399        SqlValue::Integer(value) => Ok(format!("{value:x}")),
5400        SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
5401    }
5402}
5403
5404fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5405    match row_value(row, index)? {
5406        SqlValue::Null => Ok(None),
5407        SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
5408        SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
5409        SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
5410        SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
5411    }
5412}
5413
5414fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
5415    if !value.is_finite() {
5416        bail!("SQL column {index} contained a non-finite floating-point value");
5417    }
5418
5419    if value.fract() != 0.0 {
5420        bail!("SQL column {index} contained a non-integer floating-point value");
5421    }
5422
5423    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
5424        bail!("SQL column {index} contained a floating-point value outside the i64 range");
5425    }
5426
5427    format!("{value:.0}")
5428        .parse::<i64>()
5429        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
5430}
5431
5432fn bytes_to_hex(bytes: &[u8]) -> String {
5433    let mut result = String::with_capacity(bytes.len() * 2);
5434    for byte in bytes {
5435        result.push_str(&format!("{byte:02x}"));
5436    }
5437    result
5438}
5439
5440fn query_v2_matches(
5441    connection: &Connection,
5442    query: &PackageQuery,
5443    semantics: SearchSemantics,
5444) -> Result<(Vec<V2SearchRow>, bool)> {
5445    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
5446    let limit = source_fetch_results(query, semantics);
5447    let sql = format!(
5448        "SELECT rowid, id, name, moniker, latest_version, hash \
5449         FROM packages WHERE {where_clause} LIMIT {}",
5450        limit + 1
5451    );
5452    let mut rows = query_rows(
5453        connection,
5454        &sql,
5455        params.into_iter().map(SqlValue::Text).collect(),
5456        |row| {
5457            Ok(V2SearchRow {
5458                package_rowid: row_i64(row, 0)?,
5459                id: row_string(row, 1)?,
5460                name: row_string(row, 2)?,
5461                moniker: row_opt_string(row, 3)?,
5462                version: row_string(row, 4)?,
5463                package_hash: row_hex_string(row, 5)?,
5464                match_criteria: None,
5465            })
5466        },
5467    )?;
5468    for row in &mut rows {
5469        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
5470    }
5471    let truncated = rows.len() > limit;
5472    rows.truncate(limit);
5473    Ok((rows, truncated))
5474}
5475
5476fn query_v1_matches(
5477    connection: &Connection,
5478    query: &PackageQuery,
5479    semantics: SearchSemantics,
5480) -> Result<(Vec<V1SearchRow>, bool)> {
5481    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5482    let limit = source_fetch_results(query, semantics);
5483    let sql = format!(
5484        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5485         FROM manifest \
5486         JOIN ids ON manifest.id = ids.rowid \
5487         JOIN names ON manifest.name = names.rowid \
5488         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5489         JOIN versions ON manifest.version = versions.rowid \
5490         JOIN channels ON manifest.channel = channels.rowid \
5491         WHERE {where_clause} LIMIT {}",
5492        limit + 1
5493    );
5494    let mut rows = query_rows(
5495        connection,
5496        &sql,
5497        params.into_iter().map(SqlValue::Text).collect(),
5498        |row| {
5499            Ok(V1SearchRow {
5500                manifest_rowid: row_i64(row, 0)?,
5501                package_rowid: row_i64(row, 1)?,
5502                version: row_string(row, 2)?,
5503                channel: row_string(row, 3)?,
5504                id: row_string(row, 4)?,
5505                name: row_string(row, 5)?,
5506                moniker: row_opt_string(row, 6)?,
5507                match_criteria: None,
5508            })
5509        },
5510    )?;
5511    for row in &mut rows {
5512        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5513    }
5514    let truncated = rows.len() > limit;
5515    rows.truncate(limit);
5516    Ok((rows, truncated))
5517}
5518
5519fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5520    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5521
5522    for row in rows {
5523        match grouped.get(&row.package_rowid) {
5524            Some(existing)
5525                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5526                    != Ordering::Less => {}
5527            _ => {
5528                grouped.insert(row.package_rowid, row);
5529            }
5530        }
5531    }
5532
5533    grouped.into_values().collect()
5534}
5535
5536fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5537    let has_hash = table_has_column(connection, "manifest", "hash")?;
5538    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5539    let sql = format!(
5540        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5541         FROM manifest \
5542         JOIN versions ON manifest.version = versions.rowid \
5543         JOIN channels ON manifest.channel = channels.rowid \
5544         WHERE manifest.id = ?1"
5545    );
5546    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5547        Ok(V1VersionRow {
5548            version: row_string(row, 0)?,
5549            channel: row_string(row, 1)?,
5550            pathpart_id: row_i64(row, 2)?,
5551            manifest_hash: row_opt_hex_string(row, 3)?,
5552        })
5553    })
5554}
5555
5556fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5557    let mut parts = Vec::new();
5558    let mut current = Some(pathpart_id);
5559
5560    while let Some(id) = current {
5561        let (parent, pathpart) = query_optional_value(
5562            connection,
5563            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5564            vec![SqlValue::Integer(id)],
5565            |row| {
5566                Ok((
5567                    row_opt_string(row, 0)?
5568                        .and_then(|value| value.parse::<i64>().ok())
5569                        .filter(|value| *value != 0),
5570                    row_string(row, 1)?,
5571                ))
5572            },
5573        )?
5574        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5575        parts.push(pathpart);
5576        current = parent;
5577    }
5578
5579    parts.reverse();
5580    Ok(parts.join("/"))
5581}
5582
5583fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5584    format!(
5585        "packages/{}/{}/versionData.mszyml",
5586        package_id,
5587        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5588    )
5589}
5590
5591fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5592    let message = format!("{error:#}").to_ascii_lowercase();
5593    message.contains("no such table") && message.contains("packages")
5594}
5595
5596fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5597    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5598        row_string(row, 1)
5599    })? {
5600        if value.eq_ignore_ascii_case(column) {
5601            return Ok(true);
5602        }
5603    }
5604
5605    Ok(false)
5606}
5607
5608fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5609    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5610    let id_column = if v2 { "id" } else { "ids.id" };
5611    let name_column = if v2 { "name" } else { "names.name" };
5612    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5613    let exact_match = query.exact || semantics == SearchSemantics::Single;
5614    let mut params = Vec::new();
5615
5616    if let Some(value) = &query.id {
5617        return (
5618            single_field_condition(id_column, value, exact_match, &mut params),
5619            params,
5620        );
5621    }
5622    if let Some(value) = &query.name {
5623        return (
5624            single_field_condition(name_column, value, exact_match, &mut params),
5625            params,
5626        );
5627    }
5628    if let Some(value) = &query.moniker {
5629        return (
5630            single_field_condition(moniker_column, value, exact_match, &mut params),
5631            params,
5632        );
5633    }
5634    if let Some(value) = &query.tag {
5635        return (
5636            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5637            params,
5638        );
5639    }
5640    if let Some(value) = &query.command {
5641        return (
5642            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5643            params,
5644        );
5645    }
5646    if let Some(value) = &query.query {
5647        if exact_match {
5648            let conditions = [
5649                single_field_condition(id_column, value, true, &mut params),
5650                single_field_condition(name_column, value, true, &mut params),
5651                single_field_condition(moniker_column, value, true, &mut params),
5652            ];
5653            return (format!("({})", conditions.join(" OR ")), params);
5654        }
5655
5656        let mut conditions = vec![
5657            single_field_condition(id_column, value, false, &mut params),
5658            single_field_condition(name_column, value, false, &mut params),
5659            single_field_condition(moniker_column, value, false, &mut params),
5660        ];
5661        if semantics == SearchSemantics::Many {
5662            conditions.push(mapped_field_condition(
5663                v2,
5664                "tag",
5665                value,
5666                rowid_column,
5667                false,
5668                &mut params,
5669            ));
5670            conditions.push(mapped_field_condition(
5671                v2,
5672                "command",
5673                value,
5674                rowid_column,
5675                false,
5676                &mut params,
5677            ));
5678        }
5679        return (format!("({})", conditions.join(" OR ")), params);
5680    }
5681
5682    ("1 = 1".to_owned(), Vec::new())
5683}
5684
5685fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5686    params.push(match_parameter(value, exact));
5687    format!("{column} LIKE ?{}", params.len())
5688}
5689
5690fn mapped_field_condition(
5691    v2: bool,
5692    value_name: &str,
5693    value: &str,
5694    rowid_column: &str,
5695    exact: bool,
5696    params: &mut Vec<String>,
5697) -> String {
5698    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5699        (
5700            format!("{value_name}s2"),
5701            format!("{value_name}s2_map"),
5702            value_name.to_owned(),
5703            "package".to_owned(),
5704        )
5705    } else {
5706        (
5707            format!("{value_name}s"),
5708            format!("{value_name}s_map"),
5709            value_name.to_owned(),
5710            "manifest".to_owned(),
5711        )
5712    };
5713    params.push(match_parameter(value, exact));
5714    let parameter = params.len();
5715    // Use a non-correlated `rowid IN (...)` subquery rather than a correlated
5716    // `EXISTS (... WHERE map.owner = packages.rowid ...)`. The correlated form
5717    // re-scans the (un-indexed) map table once per candidate package row, which
5718    // is quadratic: on the real winget index it takes ~19s under SQLite and
5719    // effectively hangs under the Turso engine. The `IN` form evaluates the
5720    // owner-id set a single time, collapsing the search back to milliseconds.
5721    format!(
5722        "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
5723         FROM {map_table_name} JOIN {table_name} ON \
5724         {map_table_name}.{value_name} = {table_name}.rowid \
5725         WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
5726    )
5727}
5728
5729fn match_parameter(value: &str, exact: bool) -> String {
5730    if exact { value.to_owned() } else { format!("%{value}%") }
5731}
5732
5733fn build_rest_search_body(
5734    query: &PackageQuery,
5735    info: &RestInformation,
5736    semantics: SearchSemantics,
5737) -> Result<JsonValue> {
5738    let mut root = serde_json::Map::new();
5739    root.insert(
5740        "MaximumResults".to_owned(),
5741        JsonValue::from(source_fetch_results(query, semantics) as u64),
5742    );
5743    let exact_match = query.exact || semantics == SearchSemantics::Single;
5744
5745    if let Some(value) = &query.query {
5746        if semantics == SearchSemantics::Single {
5747            let mut filters = vec![
5748                rest_filter("PackageIdentifier", value, true),
5749                rest_filter("PackageName", value, true),
5750                rest_filter("Moniker", value, true),
5751            ];
5752            append_required_rest_filters(&mut filters, info);
5753            root.insert("Filters".to_owned(), JsonValue::Array(filters));
5754            return Ok(JsonValue::Object(root));
5755        }
5756
5757        root.insert(
5758            "Query".to_owned(),
5759            serde_json::json!({
5760                "KeyWord": value,
5761                "MatchType": if exact_match { "Exact" } else { "Substring" },
5762            }),
5763        );
5764    }
5765
5766    let mut filters = Vec::new();
5767    if let Some(value) = &query.id {
5768        filters.push(rest_filter("PackageIdentifier", value, exact_match));
5769    }
5770    if let Some(value) = &query.name {
5771        filters.push(rest_filter("PackageName", value, exact_match));
5772    }
5773    if let Some(value) = &query.moniker {
5774        filters.push(rest_filter("Moniker", value, exact_match));
5775    }
5776    if let Some(value) = &query.tag {
5777        filters.push(rest_filter("Tag", value, true));
5778    }
5779    if let Some(value) = &query.command {
5780        filters.push(rest_filter("Command", value, true));
5781    }
5782    append_required_rest_filters(&mut filters, info);
5783
5784    if !filters.is_empty() {
5785        root.insert("Filters".to_owned(), JsonValue::Array(filters));
5786    }
5787
5788    Ok(JsonValue::Object(root))
5789}
5790
5791fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5792    if info
5793        .required_package_match_fields
5794        .iter()
5795        .any(|field| field.eq_ignore_ascii_case("Market"))
5796    {
5797        filters.push(rest_filter("Market", &default_market(), true));
5798    }
5799}
5800
5801fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5802    serde_json::json!({
5803        "PackageMatchField": field,
5804        "RequestMatch": {
5805            "KeyWord": value,
5806            "MatchType": if exact { "Exact" } else { "Substring" },
5807        }
5808    })
5809}
5810
5811fn max_results(query: &PackageQuery) -> usize {
5812    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5813}
5814
5815fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5816    match semantics {
5817        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5818        SearchSemantics::Single => max_results(query),
5819    }
5820}
5821
5822fn infer_preindexed_match_criteria_v2(
5823    connection: &Connection,
5824    row: &V2SearchRow,
5825    query: &PackageQuery,
5826    semantics: SearchSemantics,
5827) -> Result<Option<String>> {
5828    infer_match_criteria(
5829        &row.id,
5830        &row.name,
5831        row.moniker.as_deref(),
5832        query,
5833        semantics,
5834        |value| {
5835            find_mapped_value_v2(
5836                connection,
5837                "tags2",
5838                "tags2_map",
5839                "tag",
5840                row.package_rowid,
5841                value,
5842                query.exact,
5843            )
5844        },
5845        |value| {
5846            find_mapped_value_v2(
5847                connection,
5848                "commands2",
5849                "commands2_map",
5850                "command",
5851                row.package_rowid,
5852                value,
5853                query.exact,
5854            )
5855        },
5856    )
5857}
5858
5859fn infer_preindexed_match_criteria_v1(
5860    connection: &Connection,
5861    row: &V1SearchRow,
5862    query: &PackageQuery,
5863    semantics: SearchSemantics,
5864) -> Result<Option<String>> {
5865    infer_match_criteria(
5866        &row.id,
5867        &row.name,
5868        row.moniker.as_deref(),
5869        query,
5870        semantics,
5871        |value| {
5872            find_mapped_value_v1(
5873                connection,
5874                "tags",
5875                "tags_map",
5876                "tag",
5877                row.manifest_rowid,
5878                value,
5879                query.exact,
5880            )
5881        },
5882        |value| {
5883            find_mapped_value_v1(
5884                connection,
5885                "commands",
5886                "commands_map",
5887                "command",
5888                row.manifest_rowid,
5889                value,
5890                query.exact,
5891            )
5892        },
5893    )
5894}
5895
5896fn infer_match_criteria<FTag, FCommand>(
5897    id: &str,
5898    name: &str,
5899    moniker: Option<&str>,
5900    query: &PackageQuery,
5901    semantics: SearchSemantics,
5902    find_tag: FTag,
5903    find_command: FCommand,
5904) -> Result<Option<String>>
5905where
5906    FTag: FnOnce(&str) -> Result<Option<String>>,
5907    FCommand: FnOnce(&str) -> Result<Option<String>>,
5908{
5909    if let Some(value) = &query.tag {
5910        return Ok(Some(format_match_criteria("Tag", value)));
5911    }
5912    if let Some(value) = &query.command {
5913        return Ok(Some(format_match_criteria("Command", value)));
5914    }
5915    if let Some(value) = &query.moniker {
5916        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
5917    }
5918    if semantics == SearchSemantics::Single {
5919        return Ok(None);
5920    }
5921    if let Some(value) = &query.query {
5922        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
5923            return Ok(None);
5924        }
5925        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
5926            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
5927        }
5928        if let Some(tag) = find_tag(value)? {
5929            return Ok(Some(format_match_criteria("Tag", &tag)));
5930        }
5931        if let Some(command) = find_command(value)? {
5932            return Ok(Some(format_match_criteria("Command", &command)));
5933        }
5934    }
5935    Ok(None)
5936}
5937
5938fn find_mapped_value_v2(
5939    connection: &Connection,
5940    table_name: &str,
5941    map_table_name: &str,
5942    value_name: &str,
5943    package_rowid: i64,
5944    query: &str,
5945    exact: bool,
5946) -> Result<Option<String>> {
5947    let sql = format!(
5948        "SELECT {table_name}.{value_name} FROM {map_table_name} \
5949         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5950         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
5951    );
5952    let values = query_rows(
5953        connection,
5954        &sql,
5955        vec![
5956            SqlValue::Integer(package_rowid),
5957            SqlValue::Text(match_parameter(query, exact)),
5958        ],
5959        |row| row_string(row, 0),
5960    )?;
5961    Ok(select_best_text_match(values, query, exact))
5962}
5963
5964fn find_mapped_value_v1(
5965    connection: &Connection,
5966    table_name: &str,
5967    map_table_name: &str,
5968    value_name: &str,
5969    manifest_rowid: i64,
5970    query: &str,
5971    exact: bool,
5972) -> Result<Option<String>> {
5973    let sql = format!(
5974        "SELECT {table_name}.{value_name} FROM {map_table_name} \
5975         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5976         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
5977    );
5978    let values = query_rows(
5979        connection,
5980        &sql,
5981        vec![
5982            SqlValue::Integer(manifest_rowid),
5983            SqlValue::Text(match_parameter(query, exact)),
5984        ],
5985        |row| row_string(row, 0),
5986    )?;
5987    Ok(select_best_text_match(values, query, exact))
5988}
5989
5990fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
5991    if let Some(value) = &query.tag {
5992        return Some(format_match_criteria("Tag", value));
5993    }
5994    if let Some(value) = &query.command {
5995        return Some(format_match_criteria("Command", value));
5996    }
5997    if let Some(value) = &query.moniker {
5998        return Some(format_match_criteria("Moniker", value));
5999    }
6000    if semantics == SearchSemantics::Single {
6001        return None;
6002    }
6003    let value = query.query.as_deref()?;
6004    if json_string(item, "PackageIdentifier")
6005        .as_deref()
6006        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6007        || json_string(item, "PackageName")
6008            .as_deref()
6009            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6010    {
6011        return None;
6012    }
6013    json_string(item, "Moniker")
6014        .filter(|candidate| matches_text(candidate, value, query.exact))
6015        .map(|candidate| format_match_criteria("Moniker", &candidate))
6016}
6017
6018fn format_match_criteria(field: &str, value: &str) -> String {
6019    format!("{field}: {value}")
6020}
6021
6022fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6023    if exact {
6024        candidate.eq_ignore_ascii_case(query)
6025    } else {
6026        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6027    }
6028}
6029
6030fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6031    let mut score = 0;
6032
6033    if let Some(value) = &query.query {
6034        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6035        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6036        if let Some(moniker) = candidate.moniker.as_deref() {
6037            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6038        }
6039    }
6040
6041    if let Some(value) = &query.id {
6042        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6043    }
6044    if let Some(value) = &query.name {
6045        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6046    }
6047    if let Some(value) = &query.moniker
6048        && let Some(moniker) = candidate.moniker.as_deref()
6049    {
6050        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6051    }
6052    if let Some(value) = &query.tag
6053        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6054    {
6055        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6056    }
6057    if let Some(value) = &query.command
6058        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6059    {
6060        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6061    }
6062
6063    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6064        score += 5;
6065    }
6066    if search_match_has_unknown_version(candidate) {
6067        score = score.saturating_sub(10);
6068    }
6069
6070    score
6071}
6072
6073fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
6074    candidate
6075        .version
6076        .as_deref()
6077        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
6078}
6079
6080/// Builds the YAML manifest emitted next to the installer by `winget download`.
6081///
6082/// `documents` is the merged singleton JSON the source provided; we narrow the
6083/// `Installers` array down to just the resolved installer so the file describes
6084/// exactly the binary that was written, mirroring native winget behavior. The
6085/// typed `manifest` overrides identity fields (id, version, name, channel) on
6086/// the JSON because callers like `manifest_for_match` correct those after the
6087/// initial YAML parse.
6088fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
6089    let mut document = match documents {
6090        JsonValue::Object(map) => map.clone(),
6091        _ => serde_json::Map::new(),
6092    };
6093
6094    document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
6095    document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
6096    document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
6097    if !manifest.channel.is_empty() {
6098        document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
6099    } else {
6100        document.remove("Channel");
6101    }
6102
6103    // Replace the multi-installer array with the single resolved installer so
6104    // the emitted manifest describes the file the user actually downloaded.
6105    let mut single_installer = serde_json::Map::new();
6106    if let Some(arch) = installer.architecture.as_ref() {
6107        single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
6108    }
6109    if let Some(installer_type) = installer.installer_type.as_ref() {
6110        single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
6111    }
6112    if let Some(url) = installer.url.as_ref() {
6113        single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
6114    }
6115    if let Some(sha) = installer.sha256.as_ref() {
6116        single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
6117    }
6118    if let Some(scope) = installer.scope.as_ref() {
6119        single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
6120    }
6121    if let Some(locale) = installer.locale.as_ref() {
6122        single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
6123    }
6124    if let Some(product_code) = installer.product_code.as_ref() {
6125        single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
6126    }
6127    if let Some(release_date) = installer.release_date.as_ref() {
6128        single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
6129    }
6130
6131    document.insert(
6132        "Installers".to_owned(),
6133        JsonValue::Array(vec![JsonValue::Object(single_installer)]),
6134    );
6135    document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
6136
6137    let value = JsonValue::Object(document);
6138    serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
6139}
6140
6141/// Computes the file name `winget download` uses for the YAML manifest
6142/// emitted next to the installer:
6143/// `{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml`.
6144/// Mirrors `GetInstallerDownloadOnlyFileName` in winget-cli's DownloadFlow.
6145fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
6146    let mut filename = derive_download_filename_stem(manifest, installer);
6147    filename.push_str(".yaml");
6148    filename
6149}
6150
6151fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
6152    let mut stem = sanitize_filename_component(&manifest.name);
6153    if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
6154        stem.push('_');
6155        stem.push_str(&sanitize_filename_component(&manifest.version));
6156    }
6157    if let Some(scope) = installer.scope.as_deref() {
6158        stem.push('_');
6159        stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
6160    }
6161    let architecture = installer.architecture.as_deref().unwrap_or("neutral");
6162    stem.push('_');
6163    stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
6164
6165    let installer_type = installer.installer_type.as_deref().unwrap_or("");
6166    if !installer_type.is_empty() {
6167        stem.push('_');
6168        stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
6169    }
6170    if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
6171        stem.push('_');
6172        stem.push_str(&sanitize_filename_component(locale));
6173    }
6174
6175    stem
6176}
6177
6178fn capitalize_first(value: &str) -> String {
6179    let mut chars = value.chars();
6180    match chars.next() {
6181        Some(first) => {
6182            let mut out = first.to_ascii_uppercase().to_string();
6183            out.push_str(&chars.as_str().to_ascii_lowercase());
6184            out
6185        }
6186        None => String::new(),
6187    }
6188}
6189
6190fn format_architecture(value: &str) -> String {
6191    match value.to_ascii_lowercase().as_str() {
6192        "x64" => "X64".to_owned(),
6193        "x86" => "X86".to_owned(),
6194        "arm" => "Arm".to_owned(),
6195        "arm64" => "Arm64".to_owned(),
6196        "neutral" => "Neutral".to_owned(),
6197        other => capitalize_first(other),
6198    }
6199}
6200
6201fn sanitize_filename_component(value: &str) -> String {
6202    value
6203        .chars()
6204        .map(|ch| match ch {
6205            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
6206            ch if ch.is_control() => '_',
6207            ch => ch,
6208        })
6209        .collect()
6210}
6211
6212fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
6213    if manifest_path.is_file() {
6214        return Ok(manifest_path.to_path_buf());
6215    }
6216
6217    if !manifest_path.is_dir() {
6218        bail!("Manifest path not found: {}", manifest_path.display());
6219    }
6220
6221    let candidate = fs::read_dir(manifest_path)?
6222        .filter_map(|entry| entry.ok())
6223        .map(|entry| entry.path())
6224        .filter(|path| {
6225            path.extension()
6226                .and_then(|value| value.to_str())
6227                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
6228        })
6229        .min();
6230
6231    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
6232}
6233
6234fn score_text_match(
6235    candidate: &str,
6236    query: &str,
6237    exact: bool,
6238    exact_score: usize,
6239    prefix_score: usize,
6240    substring_score: usize,
6241) -> usize {
6242    if candidate.eq_ignore_ascii_case(query) {
6243        return exact_score;
6244    }
6245    if exact {
6246        return 0;
6247    }
6248
6249    let candidate_lower = candidate.to_ascii_lowercase();
6250    let query_lower = query.to_ascii_lowercase();
6251    if candidate_lower.starts_with(&query_lower) {
6252        prefix_score
6253    } else if candidate_lower.contains(&query_lower) {
6254        substring_score
6255    } else {
6256        0
6257    }
6258}
6259
6260fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
6261where
6262    I: IntoIterator<Item = String>,
6263{
6264    values
6265        .into_iter()
6266        .filter_map(|candidate| {
6267            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
6268            (score > 0).then_some((score, candidate))
6269        })
6270        .max_by(|(left_score, left_value), (right_score, right_value)| {
6271            left_score
6272                .cmp(right_score)
6273                .then_with(|| right_value.len().cmp(&left_value.len()))
6274                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
6275        })
6276        .map(|(_, candidate)| candidate)
6277}
6278
6279fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
6280    let (field, value) = criteria.split_once(": ")?;
6281    Some((field, value))
6282}
6283
6284fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
6285    let versions = item
6286        .get("Versions")
6287        .and_then(JsonValue::as_array)
6288        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
6289    let mut result = Vec::new();
6290    for version in versions {
6291        let value = json_string(version, "PackageVersion")
6292            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
6293        let channel = json_string(version, "Channel").unwrap_or_default();
6294        result.push(VersionKey {
6295            version: value,
6296            channel,
6297        });
6298    }
6299    Ok(result)
6300}
6301
6302fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
6303    let mut merged = YamlMapping::new();
6304    let mut documents = Vec::new();
6305    for document in serde_yaml::Deserializer::from_slice(bytes) {
6306        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
6307        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
6308        if let Some(mapping) = value.as_mapping() {
6309            merge_yaml_mapping(&mut merged, mapping);
6310        }
6311    }
6312
6313    let id = yaml_string_from_root(&merged, "PackageIdentifier")
6314        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
6315    let version =
6316        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
6317    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
6318
6319    let installers = parse_yaml_installers(&merged);
6320    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
6321    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6322
6323    Ok((
6324        Manifest {
6325            id,
6326            name,
6327            version,
6328            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
6329            publisher: yaml_localized_string(&merged, "Publisher"),
6330            description: yaml_localized_string(&merged, "Description")
6331                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
6332            moniker: yaml_string_from_root(&merged, "Moniker"),
6333            package_url: yaml_localized_string(&merged, "PackageUrl"),
6334            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
6335            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
6336            license: yaml_localized_string(&merged, "License"),
6337            license_url: yaml_localized_string(&merged, "LicenseUrl"),
6338            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
6339            author: yaml_localized_string(&merged, "Author"),
6340            copyright: yaml_localized_string(&merged, "Copyright"),
6341            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
6342            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
6343            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
6344            tags: yaml_string_list(&merged, "Tags"),
6345            agreements: yaml_agreement_list(&merged),
6346            package_dependencies: yaml_package_dependencies(&merged),
6347            documentation: yaml_documentation_list(&merged),
6348            installers,
6349            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6350        },
6351        collapse_structured_document(&JsonValue::Array(documents)),
6352    ))
6353}
6354
6355fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
6356    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
6357}
6358
6359fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
6360    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
6361    let data = root
6362        .get("Data")
6363        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
6364    let versions = data
6365        .get("Versions")
6366        .and_then(JsonValue::as_array)
6367        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
6368    let selected = versions
6369        .iter()
6370        .find(|item| {
6371            json_string(item, "PackageVersion").as_deref() == Some(version)
6372                && json_string(item, "Channel").unwrap_or_default() == channel
6373        })
6374        .or_else(|| versions.first())
6375        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
6376
6377    let default_locale = selected
6378        .get("DefaultLocale")
6379        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
6380    let name = json_string(default_locale, "PackageName")
6381        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
6382    let installer_switch_defaults = json_installer_switches(selected);
6383    let top_platforms = json_string_list(selected, "Platform");
6384    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
6385    let top_installer_type = json_string(selected, "InstallerType");
6386    let top_nested_installer_type = json_string(selected, "NestedInstallerType");
6387    let top_nested_installer_files = json_nested_installer_files(selected);
6388
6389    let installers = selected
6390        .get("Installers")
6391        .and_then(JsonValue::as_array)
6392        .map(|items| {
6393            items
6394                .iter()
6395                .map(|item| Installer {
6396                    platforms: {
6397                        let platforms = json_string_list(item, "Platform");
6398                        if platforms.is_empty() {
6399                            top_platforms.clone()
6400                        } else {
6401                            platforms
6402                        }
6403                    },
6404                    minimum_os_version: json_string(item, "MinimumOSVersion")
6405                        .or_else(|| top_minimum_os_version.clone()),
6406                    architecture: json_string(item, "Architecture"),
6407                    installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
6408                    nested_installer_type: json_string(item, "NestedInstallerType")
6409                        .or_else(|| top_nested_installer_type.clone()),
6410                    nested_installer_files: {
6411                        let files = json_nested_installer_files(item);
6412                        if files.is_empty() {
6413                            top_nested_installer_files.clone()
6414                        } else {
6415                            files
6416                        }
6417                    },
6418                    url: json_string(item, "InstallerUrl"),
6419                    sha256: json_string(item, "InstallerSha256"),
6420                    product_code: json_string(item, "ProductCode"),
6421                    locale: json_string(item, "InstallerLocale"),
6422                    scope: json_string(item, "Scope"),
6423                    release_date: json_string(item, "ReleaseDate"),
6424                    package_family_name: json_string(item, "PackageFamilyName"),
6425                    upgrade_code: json_string(item, "UpgradeCode"),
6426                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
6427                    commands: json_string_list(item, "Commands"),
6428                    package_dependencies: json_package_dependencies(item),
6429                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
6430                })
6431                .collect::<Vec<_>>()
6432        })
6433        .unwrap_or_default();
6434    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
6435    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6436
6437    let manifest = Manifest {
6438        id: package_id.to_owned(),
6439        name,
6440        version: version.to_owned(),
6441        channel: channel.to_owned(),
6442        publisher: json_string(default_locale, "Publisher"),
6443        description: json_string(default_locale, "Description")
6444            .or_else(|| json_string(default_locale, "ShortDescription")),
6445        moniker: json_string(default_locale, "Moniker"),
6446        package_url: json_string(default_locale, "PackageUrl"),
6447        publisher_url: json_string(default_locale, "PublisherUrl"),
6448        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
6449        license: json_string(default_locale, "License"),
6450        license_url: json_string(default_locale, "LicenseUrl"),
6451        privacy_url: json_string(default_locale, "PrivacyUrl"),
6452        author: json_string(default_locale, "Author"),
6453        copyright: json_string(default_locale, "Copyright"),
6454        copyright_url: json_string(default_locale, "CopyrightUrl"),
6455        release_notes: json_string(default_locale, "ReleaseNotes"),
6456        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
6457        tags: json_string_list(default_locale, "Tags"),
6458        agreements: json_agreement_list(default_locale),
6459        package_dependencies: json_package_dependencies(selected),
6460        documentation: json_documentation_list(default_locale),
6461        installers,
6462        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6463    };
6464
6465    Ok((
6466        manifest,
6467        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
6468    ))
6469}
6470
6471fn collapse_structured_document(document: &JsonValue) -> JsonValue {
6472    match document {
6473        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
6474            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
6475        }
6476        JsonValue::Array(documents) => merge_manifest_documents(documents),
6477        JsonValue::Object(_) if is_merged_manifest_document(document) => {
6478            merge_manifest_documents(&split_merged_manifest_document(document))
6479        }
6480        _ => document.clone(),
6481    }
6482}
6483
6484fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
6485    documents.iter().map(collapse_structured_document).collect()
6486}
6487
6488fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
6489    let version = documents.iter().find(|document| {
6490        document
6491            .get("ManifestType")
6492            .and_then(JsonValue::as_str)
6493            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
6494    });
6495    let default_locale = documents.iter().find(|document| {
6496        document
6497            .get("ManifestType")
6498            .and_then(JsonValue::as_str)
6499            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
6500    });
6501    let installer = documents.iter().find(|document| {
6502        document
6503            .get("ManifestType")
6504            .and_then(JsonValue::as_str)
6505            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
6506    });
6507
6508    let mut singleton = JsonMap::new();
6509    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
6510    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
6511    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
6512
6513    if !singleton.contains_key("PackageLocale")
6514        && let Some(package_locale) = default_locale
6515            .and_then(|document| json_string(document, "PackageLocale"))
6516            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
6517    {
6518        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6519    }
6520
6521    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
6522    singleton.insert(
6523        "ManifestVersion".to_owned(),
6524        JsonValue::String(
6525            installer
6526                .and_then(|document| json_string(document, "ManifestVersion"))
6527                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
6528                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
6529                .unwrap_or_else(|| "1.10.0".to_owned()),
6530        ),
6531    );
6532    singleton.remove("DefaultLocale");
6533
6534    JsonValue::Object(singleton)
6535}
6536
6537fn is_merged_manifest_document(document: &JsonValue) -> bool {
6538    document
6539        .get("ManifestType")
6540        .and_then(JsonValue::as_str)
6541        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
6542}
6543
6544fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
6545    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
6546    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
6547    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6548    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
6549
6550    let version_document = serde_json::json!({
6551        "PackageIdentifier": package_identifier,
6552        "PackageVersion": package_version,
6553        "DefaultLocale": package_locale,
6554        "ManifestType": "version",
6555        "ManifestVersion": manifest_version,
6556    });
6557
6558    let mut default_locale_document = project_manifest_document(
6559        merged,
6560        &[
6561            "PackageIdentifier",
6562            "PackageVersion",
6563            "PackageLocale",
6564            "Publisher",
6565            "PublisherUrl",
6566            "PublisherSupportUrl",
6567            "PrivacyUrl",
6568            "Author",
6569            "PackageName",
6570            "PackageUrl",
6571            "License",
6572            "LicenseUrl",
6573            "Copyright",
6574            "CopyrightUrl",
6575            "ShortDescription",
6576            "Description",
6577            "Moniker",
6578            "Tags",
6579            "Agreements",
6580            "ReleaseNotes",
6581            "ReleaseNotesUrl",
6582            "PurchaseUrl",
6583            "InstallationNotes",
6584            "Documentations",
6585            "Icons",
6586        ],
6587    );
6588    default_locale_document.insert(
6589        "PackageIdentifier".to_owned(),
6590        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6591    );
6592    default_locale_document.insert(
6593        "PackageVersion".to_owned(),
6594        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6595    );
6596    default_locale_document.insert(
6597        "PackageLocale".to_owned(),
6598        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
6599    );
6600    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6601    default_locale_document.insert(
6602        "ManifestVersion".to_owned(),
6603        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6604    );
6605
6606    let mut installer_document = project_manifest_document(
6607        merged,
6608        &[
6609            "PackageIdentifier",
6610            "PackageVersion",
6611            "Channel",
6612            "InstallerLocale",
6613            "Platform",
6614            "MinimumOSVersion",
6615            "InstallerType",
6616            "NestedInstallerType",
6617            "NestedInstallerFiles",
6618            "Scope",
6619            "InstallModes",
6620            "InstallerSwitches",
6621            "InstallerSuccessCodes",
6622            "ExpectedReturnCodes",
6623            "UpgradeBehavior",
6624            "Commands",
6625            "Protocols",
6626            "FileExtensions",
6627            "Dependencies",
6628            "PackageFamilyName",
6629            "ProductCode",
6630            "Capabilities",
6631            "RestrictedCapabilities",
6632            "Markets",
6633            "InstallerAbortsTerminal",
6634            "ReleaseDate",
6635            "InstallLocationRequired",
6636            "RequireExplicitUpgrade",
6637            "DisplayInstallWarnings",
6638            "UnsupportedOSArchitectures",
6639            "UnsupportedArguments",
6640            "AppsAndFeaturesEntries",
6641            "ElevationRequirement",
6642            "InstallationMetadata",
6643            "DownloadCommandProhibited",
6644            "RepairBehavior",
6645            "ArchiveBinariesDependOnPath",
6646            "Authentication",
6647            "Installers",
6648        ],
6649    );
6650    installer_document.insert(
6651        "PackageIdentifier".to_owned(),
6652        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6653    );
6654    installer_document.insert(
6655        "PackageVersion".to_owned(),
6656        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6657    );
6658    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6659    installer_document.insert(
6660        "ManifestVersion".to_owned(),
6661        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6662    );
6663
6664    vec![
6665        version_document,
6666        JsonValue::Object(default_locale_document),
6667        JsonValue::Object(installer_document),
6668    ]
6669}
6670
6671fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6672    let mut result = JsonMap::new();
6673    if let Some(source) = source.as_object() {
6674        for key in keys {
6675            if let Some(value) = source.get(*key) {
6676                result.insert((*key).to_owned(), value.clone());
6677            }
6678        }
6679    }
6680
6681    result
6682}
6683
6684fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6685    if let Some(source) = source.and_then(JsonValue::as_object) {
6686        for key in keys {
6687            if let Some(value) = source.get(*key) {
6688                target.insert((*key).to_owned(), value.clone());
6689            }
6690        }
6691    }
6692}
6693
6694fn copy_all_manifest_keys_except(
6695    target: &mut JsonMap<String, JsonValue>,
6696    source: Option<&JsonValue>,
6697    excluded_keys: &[&str],
6698) {
6699    if let Some(source) = source.and_then(JsonValue::as_object) {
6700        for (key, value) in source {
6701            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6702                target.insert(key.clone(), value.clone());
6703            }
6704        }
6705    }
6706}
6707
6708fn build_rest_manifest_documents(
6709    data: &JsonValue,
6710    installer_source: &JsonValue,
6711    default_locale: &JsonValue,
6712    package_id: &str,
6713    version: &str,
6714    channel: &str,
6715) -> JsonValue {
6716    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6717    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6718    let manifest_version = json_string(installer_source, "ManifestVersion")
6719        .or_else(|| json_string(default_locale, "ManifestVersion"))
6720        .or_else(|| json_string(data, "ManifestVersion"))
6721        .unwrap_or_else(|| "1.10.0".to_owned());
6722
6723    let mut version_document = JsonMap::new();
6724    version_document.insert(
6725        "PackageIdentifier".to_owned(),
6726        JsonValue::String(package_identifier.clone()),
6727    );
6728    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6729    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6730    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6731    version_document.insert(
6732        "ManifestVersion".to_owned(),
6733        JsonValue::String(manifest_version.clone()),
6734    );
6735
6736    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6737    default_locale_document.insert(
6738        "PackageIdentifier".to_owned(),
6739        JsonValue::String(package_identifier.clone()),
6740    );
6741    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6742    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6743    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6744    default_locale_document.insert(
6745        "ManifestVersion".to_owned(),
6746        JsonValue::String(manifest_version.clone()),
6747    );
6748
6749    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6750    installer_document.remove("DefaultLocale");
6751    installer_document.remove("Locales");
6752    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6753    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6754    if !channel.is_empty() {
6755        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6756    }
6757    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6758    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6759
6760    let documents = vec![
6761        JsonValue::Object(version_document),
6762        JsonValue::Object(default_locale_document),
6763        JsonValue::Object(installer_document),
6764    ];
6765
6766    merge_manifest_documents(&documents)
6767}
6768
6769fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6770    let base = installer_defaults(root);
6771    let base_switches = yaml_installer_switches(root);
6772    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6773        let installers = items
6774            .iter()
6775            .filter_map(YamlValue::as_mapping)
6776            .map(|item| {
6777                let mut merged = base.clone();
6778                merge_yaml_mapping(&mut merged, item);
6779                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6780                installer_from_yaml(&merged, switches)
6781            })
6782            .collect::<Vec<_>>();
6783        if !installers.is_empty() {
6784            return installers;
6785        }
6786    }
6787
6788    if base.is_empty() {
6789        Vec::new()
6790    } else {
6791        vec![installer_from_yaml(&base, base_switches)]
6792    }
6793}
6794
6795fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6796    let keys = [
6797        "Architecture",
6798        "InstallerType",
6799        "NestedInstallerType",
6800        "NestedInstallerFiles",
6801        "InstallerUrl",
6802        "InstallerSha256",
6803        "ProductCode",
6804        "InstallerLocale",
6805        "Platform",
6806        "MinimumOSVersion",
6807        "Scope",
6808        "ReleaseDate",
6809        "PackageFamilyName",
6810        "UpgradeCode",
6811        "Commands",
6812    ];
6813    let mut defaults = YamlMapping::new();
6814    for key in keys {
6815        if let Some(value) = root.get(YamlValue::from(key)) {
6816            defaults.insert(YamlValue::from(key), value.clone());
6817        }
6818    }
6819    defaults
6820}
6821
6822fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6823    Installer {
6824        architecture: yaml_string(root, "Architecture"),
6825        installer_type: yaml_string(root, "InstallerType"),
6826        nested_installer_type: yaml_string(root, "NestedInstallerType"),
6827        nested_installer_files: yaml_nested_installer_files(root),
6828        url: yaml_string(root, "InstallerUrl"),
6829        sha256: yaml_string(root, "InstallerSha256"),
6830        product_code: yaml_string(root, "ProductCode"),
6831        locale: yaml_string(root, "InstallerLocale"),
6832        scope: yaml_string(root, "Scope"),
6833        release_date: yaml_string(root, "ReleaseDate"),
6834        package_family_name: yaml_string(root, "PackageFamilyName"),
6835        upgrade_code: yaml_string(root, "UpgradeCode"),
6836        platforms: yaml_string_list(root, "Platform"),
6837        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6838        switches,
6839        commands: yaml_string_list(root, "Commands"),
6840        package_dependencies: yaml_package_dependencies(root),
6841        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6842    }
6843}
6844
6845/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
6846/// the legacy `True`/`False` variants the winget catalog mixes in.
6847fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6848    let Some(value) = root.get(YamlValue::from(key)) else {
6849        return false;
6850    };
6851    match value {
6852        YamlValue::Bool(b) => *b,
6853        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6854        _ => false,
6855    }
6856}
6857
6858fn json_bool(value: &JsonValue, key: &str) -> bool {
6859    match value.get(key) {
6860        Some(JsonValue::Bool(b)) => *b,
6861        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6862        _ => false,
6863    }
6864}
6865
6866fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
6867    let mapping = root
6868        .get(YamlValue::from("InstallerSwitches"))
6869        .or_else(|| root.get(YamlValue::from("Switches")))
6870        .and_then(YamlValue::as_mapping);
6871    yaml_installer_switches_from_mapping(mapping)
6872}
6873
6874fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
6875    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
6876
6877    InstallerSwitches {
6878        silent: string("Silent"),
6879        silent_with_progress: string("SilentWithProgress"),
6880        interactive: string("Interactive"),
6881        custom: string("Custom"),
6882        log: string("Log"),
6883        install_location: string("InstallLocation"),
6884    }
6885}
6886
6887fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
6888    yaml_string(root, key).or_else(|| {
6889        root.get(YamlValue::from("DefaultLocale"))
6890            .and_then(YamlValue::as_mapping)
6891            .and_then(|mapping| yaml_string(mapping, key))
6892    })
6893}
6894
6895fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
6896    yaml_string(root, key)
6897}
6898
6899fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
6900    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
6901}
6902
6903fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
6904    match value {
6905        YamlValue::Null => None,
6906        YamlValue::Bool(value) => Some(value.to_string()),
6907        YamlValue::Number(value) => Some(value.to_string()),
6908        YamlValue::String(value) => Some(value.clone()),
6909        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
6910        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
6911    }
6912}
6913
6914fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
6915    root.get(YamlValue::from(key))
6916        .and_then(YamlValue::as_sequence)
6917        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
6918        .unwrap_or_default()
6919}
6920
6921fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
6922    root.get(YamlValue::from("Documentations"))
6923        .and_then(YamlValue::as_sequence)
6924        .map(|items| {
6925            items
6926                .iter()
6927                .filter_map(YamlValue::as_mapping)
6928                .filter_map(|item| {
6929                    let url = yaml_string(item, "DocumentUrl")?;
6930                    Some(Documentation {
6931                        label: yaml_string(item, "DocumentLabel"),
6932                        url,
6933                    })
6934                })
6935                .collect()
6936        })
6937        .unwrap_or_default()
6938}
6939
6940fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
6941    root.get(YamlValue::from("Agreements"))
6942        .and_then(YamlValue::as_sequence)
6943        .map(|items| {
6944            items
6945                .iter()
6946                .filter_map(YamlValue::as_mapping)
6947                .map(|item| PackageAgreement {
6948                    label: yaml_string(item, "AgreementLabel"),
6949                    text: yaml_string(item, "Agreement"),
6950                    url: yaml_string(item, "AgreementUrl"),
6951                })
6952                .collect()
6953        })
6954        .unwrap_or_default()
6955}
6956
6957fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
6958    root.get(YamlValue::from("Dependencies"))
6959        .and_then(YamlValue::as_mapping)
6960        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
6961        .and_then(YamlValue::as_sequence)
6962        .map(|items| {
6963            items
6964                .iter()
6965                .filter_map(YamlValue::as_mapping)
6966                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
6967                .collect()
6968        })
6969        .unwrap_or_default()
6970}
6971
6972fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
6973    root.get(YamlValue::from("NestedInstallerFiles"))
6974        .and_then(YamlValue::as_sequence)
6975        .map(|items| {
6976            items
6977                .iter()
6978                .filter_map(YamlValue::as_mapping)
6979                .filter_map(|item| {
6980                    let relative = yaml_string(item, "RelativeFilePath")?;
6981                    Some(NestedInstallerFile {
6982                        relative_file_path: relative,
6983                        portable_command_alias: yaml_string(item, "PortableCommandAlias"),
6984                    })
6985                })
6986                .collect()
6987        })
6988        .unwrap_or_default()
6989}
6990
6991fn json_string(value: &JsonValue, key: &str) -> Option<String> {
6992    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
6993}
6994
6995fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
6996    value
6997        .get(key)
6998        .and_then(JsonValue::as_array)
6999        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7000        .unwrap_or_default()
7001}
7002
7003fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7004    value
7005        .get("Documentations")
7006        .and_then(JsonValue::as_array)
7007        .map(|items| {
7008            items
7009                .iter()
7010                .filter_map(|item| {
7011                    let url = json_string(item, "DocumentUrl")?;
7012                    Some(Documentation {
7013                        label: json_string(item, "DocumentLabel"),
7014                        url,
7015                    })
7016                })
7017                .collect()
7018        })
7019        .unwrap_or_default()
7020}
7021
7022fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
7023    value
7024        .get("Agreements")
7025        .and_then(JsonValue::as_array)
7026        .map(|items| {
7027            items
7028                .iter()
7029                .map(|item| PackageAgreement {
7030                    label: json_string(item, "AgreementLabel"),
7031                    text: json_string(item, "Agreement"),
7032                    url: json_string(item, "AgreementUrl"),
7033                })
7034                .collect()
7035        })
7036        .unwrap_or_default()
7037}
7038
7039fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
7040    value
7041        .get("Dependencies")
7042        .and_then(JsonValue::as_object)
7043        .and_then(|deps| deps.get("PackageDependencies"))
7044        .and_then(JsonValue::as_array)
7045        .map(|items| {
7046            items
7047                .iter()
7048                .filter_map(|item| json_string(item, "PackageIdentifier"))
7049                .collect()
7050        })
7051        .unwrap_or_default()
7052}
7053
7054fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
7055    value
7056        .get("NestedInstallerFiles")
7057        .and_then(JsonValue::as_array)
7058        .map(|items| {
7059            items
7060                .iter()
7061                .filter_map(|item| {
7062                    let relative = json_string(item, "RelativeFilePath")?;
7063                    Some(NestedInstallerFile {
7064                        relative_file_path: relative,
7065                        portable_command_alias: json_string(item, "PortableCommandAlias"),
7066                    })
7067                })
7068                .collect()
7069        })
7070        .unwrap_or_default()
7071}
7072
7073fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
7074    let switches = value
7075        .get("InstallerSwitches")
7076        .or_else(|| value.get("Switches"))
7077        .and_then(JsonValue::as_object);
7078
7079    let string = |key: &str| {
7080        switches
7081            .and_then(|switches| switches.get(key))
7082            .and_then(JsonValue::as_str)
7083            .map(str::to_owned)
7084    };
7085
7086    InstallerSwitches {
7087        silent: string("Silent"),
7088        silent_with_progress: string("SilentWithProgress"),
7089        interactive: string("Interactive"),
7090        custom: string("Custom"),
7091        log: string("Log"),
7092        install_location: string("InstallLocation"),
7093    }
7094}
7095
7096fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
7097    for (key, value) in source {
7098        target.insert(key.clone(), value.clone());
7099    }
7100}
7101
7102fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
7103    let reader = Cursor::new(bytes.to_vec());
7104    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
7105    let mut file = archive
7106        .by_name(member_name)
7107        .with_context(|| format!("zip payload missing {member_name}"))?;
7108    let mut output = Vec::new();
7109    file.read_to_end(&mut output)
7110        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
7111    Ok(output)
7112}
7113
7114fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
7115    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
7116        return Ok(output);
7117    }
7118
7119    if !bytes.starts_with(b"CK")
7120        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
7121    {
7122        return decompress_mszyml_payload(&bytes[offset + 2..]);
7123    }
7124
7125    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
7126}
7127
7128fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
7129    let mut decoder = flate2::read::DeflateDecoder::new(payload);
7130    let mut output = String::new();
7131    decoder.read_to_string(&mut output)?;
7132    Ok(output)
7133}
7134
7135fn cache_control_max_age(response: &Response) -> u64 {
7136    response
7137        .headers()
7138        .get(reqwest::header::CACHE_CONTROL)
7139        .and_then(|value| value.to_str().ok())
7140        .and_then(|header| {
7141            header.split(',').find_map(|part| {
7142                let trimmed = part.trim();
7143                trimmed
7144                    .strip_prefix("max-age=")
7145                    .and_then(|value| value.parse::<u64>().ok())
7146            })
7147        })
7148        .unwrap_or(60)
7149}
7150
7151fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
7152    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
7153        let (major, minor) = major_minor(candidate);
7154        server_versions.iter().any(|version| {
7155            let (server_major, server_minor) = major_minor(version);
7156            server_major == major && server_minor == minor
7157        })
7158    })
7159}
7160
7161fn major_minor(version: &str) -> (String, String) {
7162    let mut parts = version.split('.');
7163    (
7164        parts.next().unwrap_or_default().to_owned(),
7165        parts.next().unwrap_or_default().to_owned(),
7166    )
7167}
7168
7169fn default_market() -> String {
7170    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
7171}
7172
7173fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
7174    let requested_locale = query.locale.as_deref();
7175    let requested_architecture = query.installer_architecture.as_deref();
7176    let requested_type = query.installer_type.as_deref();
7177    let requested_scope = query.install_scope.as_deref();
7178    let requested_platform = query.platform.as_deref();
7179    let requested_os_version = query.os_version.as_deref();
7180    let system_architecture = current_architecture();
7181    let system_platform = current_platform();
7182    let current_os_version = current_os_version();
7183
7184    installers
7185        .iter()
7186        .enumerate()
7187        .filter(|(_, installer)| {
7188            installer_matches_requested(
7189                installer,
7190                requested_type,
7191                requested_scope,
7192                requested_platform,
7193                requested_os_version,
7194                system_platform,
7195                current_os_version.as_deref(),
7196            )
7197        })
7198        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
7199        .max_by_key(|(index, installer)| {
7200            (
7201                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
7202                std::cmp::Reverse(*index),
7203            )
7204        })
7205        .map(|(_, installer)| installer)
7206        .cloned()
7207}
7208
7209fn installer_matches_requested(
7210    installer: &Installer,
7211    requested_type: Option<&str>,
7212    requested_scope: Option<&str>,
7213    requested_platform: Option<&str>,
7214    requested_os_version: Option<&str>,
7215    system_platform: Option<&str>,
7216    current_os_version: Option<&str>,
7217) -> bool {
7218    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
7219        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
7220        && installer_matches_platform(installer, requested_platform, system_platform)
7221        && installer_matches_os_version(installer, requested_os_version, current_os_version)
7222}
7223
7224fn installer_matches_platform(
7225    installer: &Installer,
7226    requested_platform: Option<&str>,
7227    system_platform: Option<&str>,
7228) -> bool {
7229    if installer.platforms.is_empty() {
7230        return true;
7231    }
7232
7233    let Some(effective_platform) = requested_platform.or(system_platform) else {
7234        return true;
7235    };
7236
7237    installer
7238        .platforms
7239        .iter()
7240        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
7241}
7242
7243fn installer_matches_os_version(
7244    installer: &Installer,
7245    requested_os_version: Option<&str>,
7246    current_os_version: Option<&str>,
7247) -> bool {
7248    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
7249        return true;
7250    };
7251
7252    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
7253        return true;
7254    };
7255
7256    match (
7257        parse_dotted_version(minimum_os_version),
7258        parse_dotted_version(actual_os_version),
7259    ) {
7260        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
7261        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
7262    }
7263}
7264
7265fn installer_matches_architecture(
7266    installer: &Installer,
7267    requested_architecture: Option<&str>,
7268    system_architecture: &str,
7269) -> bool {
7270    let Some(architecture) = installer.architecture.as_deref() else {
7271        return true;
7272    };
7273
7274    let architecture = architecture.to_ascii_lowercase();
7275    if let Some(requested) = requested_architecture {
7276        return architecture.eq_ignore_ascii_case(requested);
7277    }
7278
7279    preferred_architectures(system_architecture)
7280        .iter()
7281        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
7282}
7283
7284fn installer_rank(
7285    installer: &Installer,
7286    requested_locale: Option<&str>,
7287    requested_architecture: Option<&str>,
7288    system_architecture: &str,
7289) -> (i32, i32, i32) {
7290    let architecture_rank = architecture_rank(
7291        installer.architecture.as_deref(),
7292        requested_architecture.unwrap_or(system_architecture),
7293        requested_architecture.is_some(),
7294        system_architecture,
7295    );
7296    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
7297    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
7298    (architecture_rank, locale_rank, command_rank)
7299}
7300
7301fn architecture_rank(
7302    installer_architecture: Option<&str>,
7303    preferred_architecture: &str,
7304    strict: bool,
7305    system_architecture: &str,
7306) -> i32 {
7307    let Some(value) = installer_architecture else {
7308        return 0;
7309    };
7310    if value.eq_ignore_ascii_case(preferred_architecture) {
7311        return 5;
7312    }
7313    if value.eq_ignore_ascii_case("neutral") {
7314        return 4;
7315    }
7316    if strict {
7317        return -1;
7318    }
7319
7320    preferred_architectures(system_architecture)
7321        .iter()
7322        .rev()
7323        .position(|candidate| value.eq_ignore_ascii_case(candidate))
7324        .and_then(|index| i32::try_from(index).ok())
7325        .and_then(|index| index.checked_add(1))
7326        .unwrap_or(-1)
7327}
7328
7329/// True when at least one installer in `manifest.installers` targets an
7330/// architecture the host can run. winget hides upgrades whose latest
7331/// version publishes no compatible installer.
7332fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
7333    if manifest.installers.is_empty() {
7334        return true;
7335    }
7336    let allowed = preferred_architectures(current_architecture());
7337    manifest.installers.iter().any(|installer| {
7338        let arch = installer.architecture.as_deref().unwrap_or("neutral");
7339        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
7340    })
7341}
7342
7343fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
7344    match system_architecture {
7345        "arm64" => &["arm64", "neutral", "x64", "x86"],
7346        "x64" => &["x64", "neutral", "x86"],
7347        "x86" => &["x86", "neutral"],
7348        _ => &["neutral"],
7349    }
7350}
7351
7352fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
7353    match (installer_locale, requested_locale) {
7354        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
7355        (Some(installer), Some(requested))
7356            if installer
7357                .split('-')
7358                .next()
7359                .zip(requested.split('-').next())
7360                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
7361        {
7362            2
7363        }
7364        (None, Some(_)) => 1,
7365        (Some(_), None) => 1,
7366        (None, None) => 0,
7367        _ => -1,
7368    }
7369}
7370
7371fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
7372    match requested {
7373        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
7374        None => true,
7375    }
7376}
7377
7378#[cfg(windows)]
7379fn current_architecture() -> &'static str {
7380    use std::sync::OnceLock;
7381    static CELL: OnceLock<&'static str> = OnceLock::new();
7382    CELL.get_or_init(|| {
7383        // IsWow64Process2's `native_machine` is the OS architecture even
7384        // when the process is running under emulation (x64 binary on
7385        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
7386        // mis-report the host arch in that case.
7387        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
7388        let mut process_machine: u16 = 0;
7389        let mut native_machine: u16 = 0;
7390        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
7391        // both out-pointers reference local stack u16s that outlive the call.
7392        let handle = unsafe { GetCurrentProcess() };
7393        // SAFETY: handle is valid per above; pointers are valid.
7394        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
7395        if ok != 0 {
7396            match native_machine {
7397                0xAA64 => return "arm64",
7398                0x8664 => return "x64",
7399                0x014C => return "x86",
7400                _ => {}
7401            }
7402        }
7403        compiled_architecture()
7404    })
7405}
7406
7407#[cfg(not(windows))]
7408fn current_architecture() -> &'static str {
7409    compiled_architecture()
7410}
7411
7412fn compiled_architecture() -> &'static str {
7413    match std::env::consts::ARCH {
7414        "x86_64" => "x64",
7415        "x86" => "x86",
7416        "aarch64" => "arm64",
7417        _ => "neutral",
7418    }
7419}
7420
7421fn current_platform() -> Option<&'static str> {
7422    if cfg!(windows) { Some("Windows.Desktop") } else { None }
7423}
7424
7425#[cfg(windows)]
7426fn current_os_version() -> Option<String> {
7427    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7428    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
7429    let build: String = key.get_value("CurrentBuildNumber").ok()?;
7430    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
7431    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
7432    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
7433    Some(format!("{major}.{minor}.{build}.{ubr}"))
7434}
7435
7436#[cfg(not(windows))]
7437fn current_os_version() -> Option<String> {
7438    None
7439}
7440
7441fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
7442    let mut parsed = Vec::new();
7443    for component in version.split('.') {
7444        if component.is_empty() {
7445            continue;
7446        }
7447        parsed.push(component.parse().ok()?);
7448    }
7449
7450    if parsed.is_empty() { None } else { Some(parsed) }
7451}
7452
7453fn select_v1_version(
7454    versions: &[V1VersionRow],
7455    requested_version: Option<&str>,
7456    requested_channel: Option<&str>,
7457    package_id: &str,
7458    source_name: &str,
7459) -> Result<V1VersionRow> {
7460    let mut ordered = versions.to_vec();
7461    ordered.sort_by(|left, right| {
7462        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7463    });
7464
7465    if let Some(version) = requested_version {
7466        let channel = requested_channel.unwrap_or_default();
7467        return ordered
7468            .iter()
7469            .find(|candidate| {
7470                candidate.version.eq_ignore_ascii_case(version)
7471                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7472            })
7473            .cloned()
7474            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7475    }
7476
7477    if let Some(channel) = requested_channel {
7478        return ordered
7479            .iter()
7480            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7481            .cloned()
7482            .ok_or_else(|| {
7483                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7484            });
7485    }
7486
7487    ordered
7488        .into_iter()
7489        .next()
7490        .ok_or_else(|| anyhow!("package had no versions"))
7491}
7492
7493fn select_v2_version(
7494    versions: &[PackageVersionDataEntry],
7495    requested_version: Option<&str>,
7496    requested_channel: Option<&str>,
7497    package_id: &str,
7498    source_name: &str,
7499) -> Result<PackageVersionDataEntry> {
7500    let mut ordered = versions.to_vec();
7501    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
7502
7503    if let Some(channel) = requested_channel {
7504        bail!(
7505            "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."
7506        );
7507    }
7508
7509    if let Some(version) = requested_version {
7510        return ordered
7511            .iter()
7512            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
7513            .cloned()
7514            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7515    }
7516
7517    ordered
7518        .into_iter()
7519        .next()
7520        .ok_or_else(|| anyhow!("package had no V2 versions"))
7521}
7522
7523fn missing_package_version(
7524    package_id: &str,
7525    source_name: &str,
7526    requested_version: &str,
7527    requested_channel: Option<&str>,
7528) -> anyhow::Error {
7529    anyhow!(MissingPackageVersionError {
7530        package_id: package_id.to_owned(),
7531        source_name: source_name.to_owned(),
7532        requested_version: requested_version.to_owned(),
7533        requested_channel: requested_channel.map(str::to_owned),
7534    })
7535}
7536
7537fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
7538    if let Some(refresh_error) = refresh.error.as_deref() {
7539        let missing_error = error.to_string();
7540        return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
7541    }
7542
7543    Err(error)
7544}
7545
7546fn select_rest_version<'a>(
7547    versions: &'a [VersionKey],
7548    requested_version: Option<&str>,
7549    requested_channel: Option<&str>,
7550    package_id: &str,
7551    source_name: &str,
7552) -> Result<&'a VersionKey> {
7553    if let Some(version) = requested_version {
7554        let channel = requested_channel.unwrap_or_default();
7555        return versions
7556            .iter()
7557            .find(|candidate| {
7558                candidate.version.eq_ignore_ascii_case(version)
7559                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7560            })
7561            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7562    }
7563
7564    if let Some(channel) = requested_channel {
7565        return versions
7566            .iter()
7567            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7568            .ok_or_else(|| {
7569                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7570            });
7571    }
7572
7573    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
7574}
7575
7576fn sort_versions_desc(versions: &mut [VersionKey]) {
7577    versions.sort_by(|left, right| {
7578        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7579    });
7580}
7581
7582fn compare_version_and_channel(
7583    left_version: &str,
7584    left_channel: &str,
7585    right_version: &str,
7586    right_channel: &str,
7587) -> Ordering {
7588    compare_version(left_version, right_version).then_with(|| {
7589        left_channel
7590            .to_ascii_lowercase()
7591            .cmp(&right_channel.to_ascii_lowercase())
7592    })
7593}
7594
7595fn compare_version(left: &str, right: &str) -> Ordering {
7596    let left_parts = tokenize_version(left);
7597    let right_parts = tokenize_version(right);
7598    let max_len = left_parts.len().max(right_parts.len());
7599
7600    for index in 0..max_len {
7601        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
7602        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
7603        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
7604
7605        let ordering = if let Some((left_number, right_number)) = numeric {
7606            left_number.cmp(&right_number)
7607        } else {
7608            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
7609        };
7610
7611        if ordering != Ordering::Equal {
7612            return ordering;
7613        }
7614    }
7615
7616    Ordering::Equal
7617}
7618
7619fn tokenize_version(value: &str) -> Vec<String> {
7620    let mut parts = Vec::new();
7621    let mut current = String::new();
7622    let mut current_is_digit = None;
7623
7624    for ch in value.chars() {
7625        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
7626            if !current.is_empty() {
7627                parts.push(std::mem::take(&mut current));
7628            }
7629            current_is_digit = None;
7630            continue;
7631        }
7632
7633        let is_digit = ch.is_ascii_digit();
7634        match current_is_digit {
7635            Some(previous) if previous != is_digit => {
7636                parts.push(std::mem::take(&mut current));
7637                current.push(ch);
7638                current_is_digit = Some(is_digit);
7639            }
7640            Some(_) => current.push(ch),
7641            None => {
7642                current.push(ch);
7643                current_is_digit = Some(is_digit);
7644            }
7645        }
7646    }
7647
7648    if !current.is_empty() {
7649        parts.push(current);
7650    }
7651
7652    parts
7653}
7654
7655fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
7656    if !hash_matches(Some(expected_hash), bytes) {
7657        bail!("downloaded file hash did not match expected SHA256");
7658    }
7659    Ok(())
7660}
7661
7662fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
7663    let Some(expected_hash) = expected_hash else {
7664        return Ok(());
7665    };
7666    if ignore_security_hash {
7667        return Ok(());
7668    }
7669
7670    if !hash_matches(Some(expected_hash), bytes) {
7671        let actual = sha256_hex(bytes);
7672        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
7673    }
7674    Ok(())
7675}
7676
7677fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
7678    expected_hash
7679        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
7680        .unwrap_or(true)
7681}
7682
7683fn sha256_hex(bytes: &[u8]) -> String {
7684    let digest = Sha256::digest(bytes);
7685    let mut output = String::with_capacity(digest.len() * 2);
7686    for byte in digest {
7687        output.push_str(&format!("{byte:02X}"));
7688    }
7689    output
7690}
7691
7692fn pins_db_path(app_root: &Path) -> PathBuf {
7693    if uses_packaged_layout(app_root) {
7694        app_root.join("pinning.db")
7695    } else {
7696        app_root.join("pins.db")
7697    }
7698}
7699
7700fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
7701    let mut has_current_column = false;
7702    let mut has_legacy_column = false;
7703
7704    for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
7705        match column_name.as_str() {
7706            "type" => has_current_column = true,
7707            "pin_type" => has_legacy_column = true,
7708            _ => {}
7709        }
7710    }
7711
7712    Ok(if has_current_column {
7713        Some("type")
7714    } else if has_legacy_column {
7715        Some("pin_type")
7716    } else {
7717        None
7718    })
7719}
7720
7721fn decode_pin_type(pin_type_int: i64) -> PinType {
7722    match pin_type_int {
7723        4 => PinType::Blocking,
7724        3 => PinType::Gating,
7725        _ => PinType::Pinning,
7726    }
7727}
7728
7729#[cfg(windows)]
7730fn dispatch_installer(
7731    installer_path: &Path,
7732    installer_type: &str,
7733    request: &InstallRequest,
7734    manifest: &Manifest,
7735    installer: &Installer,
7736) -> Result<i32> {
7737    use std::process::Command;
7738
7739    match installer_type {
7740        "msi" | "wix" => {
7741            let mut cmd = Command::new("msiexec");
7742            cmd.args(installer_command_arguments(
7743                installer_type,
7744                request,
7745                manifest,
7746                installer_path,
7747                installer,
7748            ));
7749            let status = cmd.status().context("failed to run msiexec")?;
7750            Ok(status.code().unwrap_or(-1))
7751        }
7752        "msix" | "appx" => {
7753            let mut cmd = Command::new("powershell");
7754            cmd.arg("-NoProfile")
7755                .arg("-Command")
7756                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7757            let status = cmd.status().context("failed to run Add-AppxPackage")?;
7758            Ok(status.code().unwrap_or(-1))
7759        }
7760        "zip" => {
7761            // A `zip` at the top-level installer position is treated as portable when
7762            // `NestedInstallerType=portable` is specified — that's the shape every
7763            // portable in the winget catalog uses (e.g. BurntSushi.ripgrep.MSVC).
7764            // Anything else still falls through to the historical extract-to-Programs
7765            // behavior, which isn't really correct but at least doesn't regress.
7766            if installer
7767                .nested_installer_type
7768                .as_deref()
7769                .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7770            {
7771                return install_portable(installer_path, request, manifest, installer);
7772            }
7773            let target = dirs::data_local_dir()
7774                .unwrap_or_else(|| PathBuf::from("."))
7775                .join("Programs");
7776            fs::create_dir_all(&target)?;
7777            let file = fs::File::open(installer_path)?;
7778            let mut archive = ZipArchive::new(file)?;
7779            archive.extract(&target)?;
7780            Ok(0)
7781        }
7782        "portable" => install_portable(installer_path, request, manifest, installer),
7783        // exe, inno, nullsoft, burn, etc.
7784        _ => {
7785            let mut cmd = Command::new(installer_path);
7786            cmd.args(installer_command_arguments(
7787                installer_type,
7788                request,
7789                manifest,
7790                installer_path,
7791                installer,
7792            ));
7793            let status = cmd.status().context("failed to run installer")?;
7794            Ok(status.code().unwrap_or(-1))
7795        }
7796    }
7797}
7798
7799#[cfg(not(windows))]
7800fn dispatch_installer(
7801    _installer_path: &Path,
7802    _installer_type: &str,
7803    _request: &InstallRequest,
7804    _manifest: &Manifest,
7805    _installer: &Installer,
7806) -> Result<i32> {
7807    bail!("Installing packages is only supported on Windows")
7808}
7809
7810#[cfg(any(windows, test))]
7811fn installer_command_arguments(
7812    installer_type: &str,
7813    request: &InstallRequest,
7814    _manifest: &Manifest,
7815    installer_path: &Path,
7816    installer: &Installer,
7817) -> Vec<String> {
7818    if let Some(override_args) = request.override_args.as_deref() {
7819        return split_installer_switches(override_args);
7820    }
7821
7822    let mut args = Vec::new();
7823    match installer_type {
7824        "msi" | "wix" => {
7825            args.push("/i".to_owned());
7826            args.push(installer_path.display().to_string());
7827        }
7828        "msix" | "appx" | "zip" => return Vec::new(),
7829        _ => {}
7830    }
7831
7832    let configured = match request.mode {
7833        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
7834        InstallerMode::SilentWithProgress => installer
7835            .switches
7836            .silent_with_progress
7837            .as_deref()
7838            .or(installer.switches.silent.as_deref()),
7839        InstallerMode::Silent => installer
7840            .switches
7841            .silent
7842            .as_deref()
7843            .or(installer.switches.silent_with_progress.as_deref()),
7844    };
7845
7846    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
7847        args.extend(split_installer_switches(value));
7848    } else {
7849        args.extend(default_installer_arguments(installer_type, request.mode));
7850    }
7851
7852    append_switches(
7853        &mut args,
7854        resolve_template_switch(
7855            installer.switches.log.as_deref(),
7856            default_log_switch(installer_type),
7857            request.log_path.as_ref().map(|path| path.display().to_string()),
7858            "<LOGPATH>",
7859        ),
7860    );
7861    append_switches(&mut args, installer.switches.custom.clone());
7862    append_switches(&mut args, request.custom.clone());
7863    append_switches(
7864        &mut args,
7865        resolve_template_switch(
7866            installer.switches.install_location.as_deref(),
7867            default_install_location_switch(installer_type),
7868            request.install_location.clone(),
7869            "<INSTALLPATH>",
7870        ),
7871    );
7872    args
7873}
7874
7875#[cfg(any(windows, test))]
7876fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
7877    match mode {
7878        InstallerMode::Interactive => Vec::new(),
7879        InstallerMode::SilentWithProgress => match installer_type {
7880            "inno" => vec![
7881                "/SP-".to_owned(),
7882                "/SILENT".to_owned(),
7883                "/SUPPRESSMSGBOXES".to_owned(),
7884                "/NORESTART".to_owned(),
7885            ],
7886            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
7887            "nullsoft" | "nsis" => vec!["/S".to_owned()],
7888            _ => vec!["/SILENT".to_owned()],
7889        },
7890        InstallerMode::Silent => match installer_type {
7891            "inno" => vec![
7892                "/SP-".to_owned(),
7893                "/VERYSILENT".to_owned(),
7894                "/SUPPRESSMSGBOXES".to_owned(),
7895                "/NORESTART".to_owned(),
7896            ],
7897            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
7898            "nullsoft" | "nsis" => vec!["/S".to_owned()],
7899            _ => vec!["/S".to_owned()],
7900        },
7901    }
7902}
7903
7904#[cfg(any(windows, test))]
7905fn default_log_switch(installer_type: &str) -> Option<&'static str> {
7906    match installer_type {
7907        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
7908        "inno" => Some("/LOG=\"<LOGPATH>\""),
7909        _ => None,
7910    }
7911}
7912
7913#[cfg(any(windows, test))]
7914fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
7915    match installer_type {
7916        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
7917        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
7918        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
7919        _ => None,
7920    }
7921}
7922
7923#[cfg(any(windows, test))]
7924fn resolve_template_switch(
7925    manifest_value: Option<&str>,
7926    fallback: Option<&str>,
7927    replacement: Option<String>,
7928    token: &str,
7929) -> Option<String> {
7930    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
7931    let replacement = replacement?;
7932    Some(template.replace(token, &replacement))
7933}
7934
7935#[cfg(any(windows, test))]
7936fn append_switches(args: &mut Vec<String>, value: Option<String>) {
7937    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
7938        args.extend(split_installer_switches(&value));
7939    }
7940}
7941
7942#[cfg(any(windows, test))]
7943fn split_installer_switches(value: &str) -> Vec<String> {
7944    let mut args = Vec::new();
7945    let mut current = String::new();
7946    let mut in_quotes = false;
7947
7948    for ch in value.chars() {
7949        match ch {
7950            '"' => in_quotes = !in_quotes,
7951            c if c.is_whitespace() && !in_quotes => {
7952                if !current.is_empty() {
7953                    args.push(std::mem::take(&mut current));
7954                }
7955            }
7956            _ => current.push(ch),
7957        }
7958    }
7959
7960    if !current.is_empty() {
7961        args.push(current);
7962    }
7963
7964    args
7965}
7966
7967#[cfg(windows)]
7968fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7969    if request.purge && request.preserve {
7970        bail!("--purge and --preserve cannot be used together.");
7971    }
7972
7973    if installed
7974        .installer_category
7975        .as_deref()
7976        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7977    {
7978        return uninstall_portable(installed, request);
7979    }
7980
7981    if (request.purge || request.preserve) && !request.force {
7982        bail!("--purge and --preserve are currently only supported for portable packages.");
7983    }
7984
7985    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
7986        return Ok(exit_code);
7987    }
7988
7989    if let Some(exit_code) = try_uninstall_msix(installed)? {
7990        return Ok(exit_code);
7991    }
7992
7993    bail!(
7994        "No uninstall command found for installed package '{}' ({})",
7995        installed.name,
7996        installed.local_id
7997    );
7998}
7999
8000#[cfg(windows)]
8001fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8002    use std::os::windows::process::CommandExt;
8003    use std::process::Command;
8004
8005    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8006    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8007
8008    let arp_paths = [
8009        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
8010        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
8011    ];
8012
8013    for root in [&hklm, &hkcu] {
8014        for arp_path in &arp_paths {
8015            if let Ok(key) = root.open_subkey(arp_path) {
8016                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
8017                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
8018                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
8019                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
8020                        if !registry_entry_matches_installed_package(
8021                            &subkey_name,
8022                            &display_name,
8023                            product_code.as_deref(),
8024                            installed,
8025                        ) {
8026                            continue;
8027                        }
8028
8029                        if let Some(exit_code) =
8030                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
8031                        {
8032                            return Ok(Some(exit_code));
8033                        }
8034
8035                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
8036                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
8037                            subkey.get_value("UninstallString").ok()
8038                        } else {
8039                            quiet_uninstall_cmd
8040                                .clone()
8041                                .or_else(|| subkey.get_value("UninstallString").ok())
8042                        }
8043                        .context("No uninstall command found in registry")?;
8044
8045                        if is_winget_uninstall_command(&uninstall_cmd)
8046                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
8047                        {
8048                            return Ok(Some(exit_code));
8049                        }
8050
8051                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
8052                        let uninstall_line = build_uninstall_command_with_mode(
8053                            &uninstall_cmd,
8054                            request.mode,
8055                            quiet_uninstall_cmd.is_some(),
8056                            log_path.as_deref(),
8057                        );
8058                        // `UninstallString` is a complete command line and may already contain
8059                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
8060                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
8061                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
8062                        // rejects with "is not recognized as an internal or external command".
8063                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
8064                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
8065                        // automatic quoting so we can feed cmd the literal command line.
8066                        let mut cmd = Command::new("cmd");
8067                        cmd.raw_arg("/S /C");
8068                        cmd.raw_arg(format!("\"{uninstall_line}\""));
8069                        let status = cmd.status().context("failed to run uninstaller")?;
8070                        return Ok(Some(status.code().unwrap_or(-1)));
8071                    }
8072                }
8073            }
8074        }
8075    }
8076
8077    Ok(None)
8078}
8079
8080#[cfg(windows)]
8081fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
8082    use std::process::Command;
8083
8084    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
8085        return Ok(None);
8086    }
8087
8088    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
8089    let mut cmd = Command::new("powershell");
8090    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
8091        package_full_name,
8092        &installed.package_family_names,
8093    ));
8094    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
8095    Ok(Some(status.code().unwrap_or(-1)))
8096}
8097
8098#[cfg(windows)]
8099fn arp_subkey_name(local_id: &str) -> Option<&str> {
8100    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
8101}
8102
8103#[cfg(windows)]
8104fn registry_entry_matches_installed_package(
8105    subkey_name: &str,
8106    display_name: &str,
8107    product_code: Option<&str>,
8108    installed: &ListMatch,
8109) -> bool {
8110    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
8111        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
8112    {
8113        return true;
8114    }
8115
8116    if installed.product_codes.iter().any(|code| {
8117        code.eq_ignore_ascii_case(subkey_name)
8118            || product_code
8119                .map(|value| code.eq_ignore_ascii_case(value))
8120                .unwrap_or(false)
8121    }) {
8122        return true;
8123    }
8124
8125    display_name.eq_ignore_ascii_case(&installed.name)
8126}
8127
8128#[cfg(all(windows, test))]
8129fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
8130    build_uninstall_command_with_mode(
8131        uninstall_cmd,
8132        if silent {
8133            InstallerMode::Silent
8134        } else {
8135            InstallerMode::Interactive
8136        },
8137        has_quiet_command,
8138        None,
8139    )
8140}
8141
8142#[cfg(windows)]
8143fn build_uninstall_command_with_mode(
8144    uninstall_cmd: &str,
8145    mode: InstallerMode,
8146    has_quiet_command: bool,
8147    log_path: Option<&str>,
8148) -> String {
8149    let uninstall_cmd = log_path
8150        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
8151        .unwrap_or_else(|| uninstall_cmd.to_owned());
8152    if mode == InstallerMode::Interactive || has_quiet_command {
8153        return uninstall_cmd;
8154    }
8155
8156    let lower = uninstall_cmd.to_ascii_lowercase();
8157    if is_winget_uninstall_command_lower(&lower) {
8158        return uninstall_cmd;
8159    }
8160    if lower.contains("/quiet")
8161        || lower.contains("/passive")
8162        || lower.contains("/verysilent")
8163        || lower.contains("/silent")
8164        || lower.contains(" /s")
8165    {
8166        return uninstall_cmd;
8167    }
8168
8169    format!("{uninstall_cmd} /S")
8170}
8171
8172#[cfg(windows)]
8173fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8174    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
8175        return Ok(None);
8176    };
8177
8178    let exit_code = run_winget_uninstall(&args)?;
8179    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
8180        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
8181            return Ok(Some(exit_code));
8182        };
8183
8184        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
8185    }
8186
8187    Ok(Some(exit_code))
8188}
8189
8190#[cfg(windows)]
8191fn run_winget_uninstall(args: &[String]) -> Result<i32> {
8192    use std::process::Command;
8193
8194    let status = Command::new("winget")
8195        .args(args)
8196        .status()
8197        .context("failed to run delegated winget uninstall")?;
8198    Ok(status.code().unwrap_or(-1))
8199}
8200
8201#[cfg(windows)]
8202fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
8203    build_winget_uninstall_arguments_with_scope(installed, request, true)
8204}
8205
8206#[cfg(windows)]
8207fn build_winget_uninstall_arguments_with_scope(
8208    installed: &ListMatch,
8209    request: &UninstallRequest,
8210    include_scope: bool,
8211) -> Option<Vec<String>> {
8212    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
8213        return None;
8214    }
8215
8216    let mut args = vec![
8217        "uninstall".to_owned(),
8218        "--id".to_owned(),
8219        installed.id.clone(),
8220        "--exact".to_owned(),
8221        "--disable-interactivity".to_owned(),
8222    ];
8223
8224    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
8225        args.push("--source".to_owned());
8226        args.push(source_name.to_owned());
8227    }
8228
8229    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
8230        args.push("--scope".to_owned());
8231        args.push(scope.to_owned());
8232    }
8233
8234    if request.mode == InstallerMode::Silent {
8235        args.push("--silent".to_owned());
8236    }
8237
8238    Some(args)
8239}
8240
8241#[cfg(windows)]
8242fn is_winget_uninstall_command(command: &str) -> bool {
8243    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
8244}
8245
8246#[cfg(windows)]
8247fn is_winget_uninstall_command_lower(command: &str) -> bool {
8248    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
8249}
8250
8251#[cfg(windows)]
8252fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
8253    let full_name_literal = package_full_name
8254        .map(|value| format!("'{}'", value.replace('\'', "''")))
8255        .unwrap_or_else(|| "$null".to_owned());
8256    let family_names_literal = if package_family_names.is_empty() {
8257        "@()".to_owned()
8258    } else {
8259        format!(
8260            "@({})",
8261            package_family_names
8262                .iter()
8263                .map(|value| format!("'{}'", value.replace('\'', "''")))
8264                .collect::<Vec<_>>()
8265                .join(", ")
8266        )
8267    };
8268
8269    format!(
8270        "$fullName = {full_name_literal}; \
8271         $familyNames = {family_names_literal}; \
8272         $targets = Get-AppxPackage | Where-Object {{ \
8273         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
8274         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
8275         if (-not $targets) {{ exit 1 }}; \
8276         $targets | Remove-AppxPackage"
8277    )
8278}
8279
8280#[cfg(windows)]
8281fn try_run_msi_uninstall(
8282    installed: &ListMatch,
8283    subkey_name: &str,
8284    product_code: Option<&str>,
8285    request: &UninstallRequest,
8286) -> Result<Option<i32>> {
8287    use std::process::Command;
8288
8289    let uninstall_code = installed
8290        .product_codes
8291        .iter()
8292        .map(String::as_str)
8293        .chain(product_code)
8294        .chain(Some(subkey_name))
8295        .find(|value| is_product_code_like(value));
8296
8297    let Some(uninstall_code) = uninstall_code else {
8298        return Ok(None);
8299    };
8300
8301    let mut cmd = Command::new("msiexec");
8302    cmd.arg("/x").arg(uninstall_code);
8303    match request.mode {
8304        InstallerMode::Interactive => {}
8305        InstallerMode::SilentWithProgress => {
8306            cmd.arg("/passive").arg("/norestart");
8307        }
8308        InstallerMode::Silent => {
8309            cmd.arg("/quiet").arg("/norestart");
8310        }
8311    }
8312    if let Some(log_path) = &request.log_path {
8313        cmd.arg("/log").arg(log_path);
8314    }
8315
8316    let status = cmd.status().context("failed to run msiexec uninstall")?;
8317    Ok(Some(status.code().unwrap_or(-1)))
8318}
8319
8320#[cfg(windows)]
8321fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
8322    // Read PortableSymlinkFullPath from the ARP entry *before* we touch the
8323    // registry, so we can remove winget-created shims in Links\ if any. Without
8324    // this, a portable that winget originally installed (with a
8325    // PortableCommandAlias set in its manifest) would leave a dangling symlink
8326    // in %LOCALAPPDATA%\Microsoft\WinGet\Links\ once pinget uninstalls it.
8327    let known_symlink = read_portable_symlink_full_path(installed);
8328
8329    let Some(location) = installed.install_location.as_deref() else {
8330        if request.force {
8331            try_remove_portable_symlinks(installed, &known_symlink, None);
8332            try_remove_portable_registry_entry(installed);
8333            return Ok(0);
8334        }
8335        bail!(
8336            "Portable package '{}' does not expose an install location.",
8337            installed.name
8338        );
8339    };
8340
8341    if request.preserve {
8342        return Ok(0);
8343    }
8344
8345    let path = Path::new(location);
8346    let removed = if path.is_dir() {
8347        fs::remove_dir_all(path)?;
8348        true
8349    } else if path.is_file() {
8350        fs::remove_file(path)?;
8351        true
8352    } else {
8353        false
8354    };
8355
8356    if removed || request.force {
8357        // Once the files are gone (or the user forced past missing files), drop
8358        // the symlink shim and ARP entry too so the package fully disappears.
8359        try_remove_portable_symlinks(installed, &known_symlink, Some(path));
8360        try_remove_portable_registry_entry(installed);
8361        return Ok(0);
8362    }
8363
8364    bail!("Portable package location not found: {location}")
8365}
8366
8367/// Reads `PortableSymlinkFullPath` from the ARP subkey backing `installed`
8368/// when present. This value is recorded for portable installs that created a
8369/// `Links\<alias>.exe` symlink, including pinget-installed entries when
8370/// `install_portable` created that link. Entries without a created/recorded
8371/// portable symlink return `None`.
8372#[cfg(windows)]
8373fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
8374    const ARP_PREFIX: &str = r"ARP\";
8375    if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
8376        let mut parts = rest.splitn(3, '\\');
8377        let scope = parts.next().unwrap_or("");
8378        let _arch = parts.next();
8379        let subkey_name = parts.next().unwrap_or("");
8380        if subkey_name.is_empty() {
8381            return None;
8382        }
8383        let hive = if scope.eq_ignore_ascii_case("User") {
8384            RegKey::predef(HKEY_CURRENT_USER)
8385        } else {
8386            RegKey::predef(HKEY_LOCAL_MACHINE)
8387        };
8388        let subkey = hive
8389            .open_subkey(format!(
8390                r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
8391            ))
8392            .ok()?;
8393        return read_reg_string(&subkey, "PortableSymlinkFullPath");
8394    }
8395    None
8396}
8397
8398/// Removes the symlink shims winget would have created for this portable.
8399/// Tries (1) the exact path from `PortableSymlinkFullPath` if known, and
8400/// (2) any symlink in `%LOCALAPPDATA%\Microsoft\WinGet\Links\` whose target
8401/// resolves under `install_location` (defensive sweep for shims winget made
8402/// but didn't surface in the registry). Best-effort: failures are swallowed.
8403#[cfg(windows)]
8404fn try_remove_portable_symlinks(
8405    _installed: &ListMatch,
8406    known_symlink: &Option<String>,
8407    install_location: Option<&Path>,
8408) {
8409    if let Some(path) = known_symlink.as_deref() {
8410        let _ = fs::remove_file(path);
8411    }
8412
8413    // Defensive sweep: walk Links\ and remove shims whose stored target was
8414    // inside (or equal to) install_location. By this point install_location is
8415    // already deleted, so we compare the symlink's stored target string (which
8416    // the filesystem retains even after the target itself is gone).
8417    let Some(install_dir) = install_location else { return };
8418
8419    let Some(local) = dirs::data_local_dir() else { return };
8420    let links_root = local.join("Microsoft").join("WinGet").join("Links");
8421    let Ok(entries) = fs::read_dir(&links_root) else { return };
8422    for entry in entries.flatten() {
8423        let path = entry.path();
8424        let Ok(target) = fs::read_link(&path) else { continue };
8425        if path_starts_with_case_insensitive(&target, install_dir) {
8426            let _ = fs::remove_file(&path);
8427        }
8428    }
8429}
8430
8431/// Case-insensitive, component-aware path-prefix check for Windows. A raw
8432/// `str::starts_with` on the path strings would let `C:\Foo` falsely match
8433/// `C:\Foobar`; iterating path components avoids that. Used by the Links\
8434/// sweep so an uninstall of one portable can't accidentally delete shims that
8435/// belong to a different portable whose install dir happens to share a
8436/// string-prefix.
8437#[cfg(windows)]
8438fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
8439    let lower_components = |p: &Path| -> Vec<String> {
8440        p.components()
8441            .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
8442            .collect()
8443    };
8444    let parent_components = lower_components(parent);
8445    let child_components = lower_components(child);
8446    if parent_components.is_empty() || parent_components.len() > child_components.len() {
8447        return false;
8448    }
8449    parent_components
8450        .iter()
8451        .zip(child_components.iter())
8452        .all(|(a, b)| a == b)
8453}
8454
8455/// Best-effort removal of the HKCU/HKLM ARP subkey backing a portable entry.
8456/// We match on local_id (`ARP\<scope>\<arch>\<subkey>`) when present so we
8457/// delete the exact key the list view surfaced; otherwise we walk the standard
8458/// Uninstall path and match on WinGetPackageIdentifier. Failures are logged via
8459/// the returned warning chain in callers — we never bubble a registry-cleanup
8460/// failure as a hard error because the file deletion already succeeded.
8461#[cfg(windows)]
8462fn try_remove_portable_registry_entry(installed: &ListMatch) {
8463    // Collect (is_hkcu, subkey_path) pairs so we can re-open hives at delete
8464    // time instead of holding clones of RegKey (which doesn't implement Clone).
8465    let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
8466        // local_id format is `ARP\<scope>\<arch>\<subkey>` — pick the right hive/path.
8467        let mut parts = rest.splitn(3, '\\');
8468        let scope = parts.next().unwrap_or("");
8469        let _arch = parts.next();
8470        let subkey_name = parts.next().unwrap_or("");
8471        if subkey_name.is_empty() {
8472            return;
8473        }
8474        let is_hkcu = scope.eq_ignore_ascii_case("User");
8475        vec![(
8476            is_hkcu,
8477            format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
8478        )]
8479    } else {
8480        // Fall back to scanning by WinGetPackageIdentifier.
8481        let mut found = Vec::new();
8482        for (is_hkcu, hive) in [
8483            (true, RegKey::predef(HKEY_CURRENT_USER)),
8484            (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
8485        ] {
8486            let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
8487                continue;
8488            };
8489            for name in uninstall.enum_keys().flatten() {
8490                let Ok(subkey) = uninstall.open_subkey(&name) else {
8491                    continue;
8492                };
8493                if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
8494                    found.push((
8495                        is_hkcu,
8496                        format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
8497                    ));
8498                }
8499            }
8500        }
8501        found
8502    };
8503
8504    for (is_hkcu, path) in candidates {
8505        let hive = if is_hkcu {
8506            RegKey::predef(HKEY_CURRENT_USER)
8507        } else {
8508            RegKey::predef(HKEY_LOCAL_MACHINE)
8509        };
8510        let _ = hive.delete_subkey_all(&path);
8511    }
8512}
8513
8514#[cfg(windows)]
8515fn is_product_code_like(value: &str) -> bool {
8516    value.starts_with('{') && value.ends_with('}')
8517}
8518
8519#[cfg(not(windows))]
8520fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
8521    bail!("Uninstalling packages is only supported on Windows")
8522}
8523
8524/// Installs (or upgrades) a portable WinGet package.
8525///
8526/// Handles both `InstallerType: portable` (single binary download) and
8527/// `InstallerType: zip` with `NestedInstallerType: portable` (zip containing
8528/// the binary at a relative path), which is the shape every portable in the
8529/// community winget catalog uses today.
8530///
8531/// Behavior matches winget's portable workflow closely enough that the resulting
8532/// HKCU ARP entry is recognized by `winget list` and by pinget's own list view:
8533/// - Resolves the target install directory in this priority order:
8534///   1. `request.install_location` if provided (passed for upgrades)
8535///   2. Existing `InstallLocation` from the registry ARP entry (so upgrades
8536///      with no explicit location still preserve the user's custom path)
8537///   3. WinGet's default user portable root, `%LOCALAPPDATA%\Microsoft\WinGet
8538///      \Packages\<PackageId>_<SourceId>`
8539/// - If an existing pinget-owned install lives in that directory
8540///   (`InstallDirectoryCreated=1`), cleans its contents before extracting the
8541///   new version so leftover files from older versions don't pile up.
8542/// - Extracts the zip (or copies the standalone binary), then writes the ARP
8543///   registry entry that `winget list` and pinget's `collect_uninstall_view`
8544///   both read from.
8545#[cfg(windows)]
8546fn install_portable(
8547    installer_path: &Path,
8548    request: &InstallRequest,
8549    manifest: &Manifest,
8550    installer: &Installer,
8551) -> Result<i32> {
8552    let source_identifier = portable_source_identifier(&request.query);
8553    let existing_entry = read_existing_portable_entry(&manifest.id);
8554    // Reuse the existing subkey name on upgrade so we don't orphan the prior
8555    // entry by writing to a different one.
8556    let subkey_name = existing_entry
8557        .as_ref()
8558        .map(|entry| entry.subkey_name.clone())
8559        .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
8560
8561    let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
8562
8563    // If we created this directory on a previous install, wipe it clean so the
8564    // new version doesn't co-exist with leftovers from the previous one. We do
8565    // not touch a directory we don't own (InstallDirectoryCreated != 1), to
8566    // avoid stomping user data.
8567    let prev_dir_created = existing_entry
8568        .as_ref()
8569        .and_then(|entry| entry.install_directory_created)
8570        == Some(1);
8571    let dir_existed = target_dir.is_dir();
8572    if prev_dir_created && dir_existed {
8573        clean_directory_contents(&target_dir).with_context(|| {
8574            format!(
8575                "failed to clean existing portable install directory: {}",
8576                target_dir.display()
8577            )
8578        })?;
8579    }
8580
8581    fs::create_dir_all(&target_dir)
8582        .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
8583    // The "we created it" flag stays sticky across upgrades — if a previous
8584    // install marked it created, it still counts as created by us even when
8585    // the dir already existed this round.
8586    let install_directory_created = !dir_existed || prev_dir_created;
8587
8588    let installer_type = installer.installer_type.as_deref().unwrap_or("");
8589    let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
8590        let file = fs::File::open(installer_path)
8591            .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
8592        let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
8593        archive
8594            .extract(&target_dir)
8595            .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
8596        // For nested-portable zips the binary lives at the RelativeFilePath the
8597        // manifest declares. We record the first one as PortableTargetFullPath so
8598        // winget's portable uninstall workflow can identify which file to remove.
8599        installer
8600            .nested_installer_files
8601            .first()
8602            .map(|file| target_dir.join(&file.relative_file_path))
8603            .unwrap_or_else(|| target_dir.clone())
8604    } else {
8605        // Standalone portable: copy the downloaded file into target_dir using
8606        // its original filename. winget would optionally rename to the
8607        // PortableCommandAlias; pinget keeps the original name for now.
8608        let basename = installer_path
8609            .file_name()
8610            .ok_or_else(|| anyhow!("portable installer has no filename"))?;
8611        let copied = target_dir.join(basename);
8612        fs::copy(installer_path, &copied)
8613            .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
8614        copied
8615    };
8616
8617    // Create the Links\<alias>.exe shim so users can invoke the portable from
8618    // any shell, matching winget's portable workflow. Failures are non-fatal —
8619    // when the user lacks SeCreateSymbolicLink (no Developer Mode, not admin)
8620    // we still leave the package installed at install_location.
8621    let alias = determine_portable_alias(installer);
8622    let symlink_full_path = match alias.as_deref() {
8623        Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
8624        _ => None,
8625    };
8626
8627    // Ensure %LOCALAPPDATA%\Microsoft\WinGet\Links is on user PATH so the
8628    // symlink we just created is resolvable from new shells. Tracks whether we
8629    // added it this round so uninstall can decide whether to take it back out.
8630    let added_to_path = if symlink_full_path.is_some() {
8631        try_add_links_dir_to_user_path()
8632    } else {
8633        false
8634    };
8635
8636    write_portable_arp_entry(&PortableArpEntry {
8637        subkey_name: &subkey_name,
8638        install_location: &target_dir,
8639        portable_target_full_path: &portable_target_full_path,
8640        portable_symlink_full_path: symlink_full_path.as_deref(),
8641        install_directory_created,
8642        added_to_path,
8643        source_identifier: &source_identifier,
8644        manifest,
8645    })?;
8646
8647    Ok(0)
8648}
8649
8650#[cfg(windows)]
8651struct PortableArpEntry<'a> {
8652    subkey_name: &'a str,
8653    install_location: &'a Path,
8654    portable_target_full_path: &'a Path,
8655    portable_symlink_full_path: Option<&'a Path>,
8656    install_directory_created: bool,
8657    added_to_path: bool,
8658    source_identifier: &'a str,
8659    manifest: &'a Manifest,
8660}
8661
8662/// Picks the portable command alias from the manifest. Mirrors winget's
8663/// resolution: nested-installer files first (used by zip+portable manifests),
8664/// then the top-level `Commands` field (used by standalone portable manifests).
8665/// Returns None when the manifest doesn't declare a command — the package
8666/// still installs, just without a Links\ shim.
8667#[cfg(windows)]
8668fn determine_portable_alias(installer: &Installer) -> Option<String> {
8669    installer
8670        .nested_installer_files
8671        .iter()
8672        .find_map(|file| file.portable_command_alias.clone())
8673        .or_else(|| installer.commands.first().cloned())
8674}
8675
8676#[cfg(windows)]
8677fn winget_links_dir() -> PathBuf {
8678    dirs::data_local_dir()
8679        .unwrap_or_else(|| PathBuf::from("."))
8680        .join("Microsoft")
8681        .join("WinGet")
8682        .join("Links")
8683}
8684
8685/// Creates (or replaces) `Links\<alias>.exe` as a file symlink pointing at the
8686/// portable's binary. Returns the link path on success. Returns None and logs
8687/// a warning if the user can't create symlinks (no Developer Mode, not admin)
8688/// so the install can still complete without the shim.
8689#[cfg(windows)]
8690fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
8691    use std::os::windows::fs::symlink_file;
8692    let links_dir = winget_links_dir();
8693    if fs::create_dir_all(&links_dir).is_err() {
8694        return None;
8695    }
8696    let link_path = links_dir.join(format!("{alias}.exe"));
8697
8698    // Replace any prior link/file at the path so upgrades repoint to the new
8699    // binary instead of leaving a stale symlink behind.
8700    if link_path.symlink_metadata().is_ok() {
8701        let _ = fs::remove_file(&link_path);
8702    }
8703
8704    // Symlink creation fails with ERROR_PRIVILEGE_NOT_HELD (1314) when the
8705    // process lacks SeCreateSymbolicLink (no Developer Mode, not admin). Treat
8706    // that — and any other I/O failure — as non-fatal: the package files are
8707    // already in place, just without a Links\ shim.
8708    symlink_file(target, &link_path).ok().map(|()| link_path)
8709}
8710
8711/// Adds `Links\` to the user's HKCU\Environment\Path if not already present,
8712/// then broadcasts WM_SETTINGCHANGE so explorer-spawned shells pick up the
8713/// new PATH without a logoff. Returns true if we actually appended PATH this
8714/// call — callers should write `InstallDirectoryAddedToPath` accordingly so a
8715/// follow-up uninstall doesn't take PATH back out for other portables.
8716#[cfg(windows)]
8717fn try_add_links_dir_to_user_path() -> bool {
8718    let links_dir = winget_links_dir();
8719    let links_str = links_dir.to_string_lossy().to_string();
8720    let normalized_links = links_str.to_ascii_lowercase();
8721
8722    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8723    let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
8724        return false;
8725    };
8726    let existing: String = env_key.get_value("Path").unwrap_or_default();
8727    let already_present = existing
8728        .split(';')
8729        .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
8730    if already_present {
8731        return false;
8732    }
8733
8734    let new_path = if existing.is_empty() {
8735        links_str
8736    } else if existing.ends_with(';') {
8737        format!("{existing}{links_str}")
8738    } else {
8739        format!("{existing};{links_str}")
8740    };
8741
8742    if env_key.set_value("Path", &new_path).is_err() {
8743        return false;
8744    }
8745
8746    broadcast_environment_change();
8747    true
8748}
8749
8750#[cfg(windows)]
8751fn broadcast_environment_change() {
8752    use std::ffi::OsStr;
8753    use std::os::windows::ffi::OsStrExt;
8754
8755    use windows_sys::Win32::UI::WindowsAndMessaging::{
8756        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
8757    };
8758
8759    let param: Vec<u16> = OsStr::new("Environment")
8760        .encode_wide()
8761        .chain(std::iter::once(0))
8762        .collect();
8763    // SAFETY: SendMessageTimeoutW only reads `param` for the duration of the
8764    // call (it copies the string before returning), and `result` is a stack
8765    // local we provide. No aliasing or lifetime issues.
8766    unsafe {
8767        let mut result: usize = 0;
8768        SendMessageTimeoutW(
8769            HWND_BROADCAST,
8770            WM_SETTINGCHANGE,
8771            0usize,
8772            param.as_ptr() as isize,
8773            SMTO_ABORTIFHUNG,
8774            5000,
8775            &mut result,
8776        );
8777    }
8778}
8779
8780/// Source identifier embedded in the ARP subkey name. We mirror winget for
8781/// the community repo so winget's UninstallString can resolve the subkey and
8782/// `winget list` still finds the package; everything else uses the source
8783/// name verbatim.
8784#[cfg(windows)]
8785fn portable_source_identifier(query: &PackageQuery) -> String {
8786    match query.source.as_deref() {
8787        Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8788        Some(name) if !name.is_empty() => name.to_owned(),
8789        _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8790    }
8791}
8792
8793#[cfg(windows)]
8794fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
8795    format!("{package_id}_{source_identifier}")
8796}
8797
8798#[cfg(windows)]
8799struct ExistingPortableEntry {
8800    install_location: Option<PathBuf>,
8801    install_directory_created: Option<u32>,
8802    subkey_name: String,
8803}
8804
8805#[cfg(windows)]
8806fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
8807    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8808    let uninstall = hkcu
8809        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
8810        .ok()?;
8811    for name in uninstall.enum_keys().flatten() {
8812        let Ok(subkey) = uninstall.open_subkey(&name) else {
8813            continue;
8814        };
8815        if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
8816            continue;
8817        }
8818        if !read_reg_string(&subkey, "WinGetInstallerType")
8819            .as_deref()
8820            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8821        {
8822            continue;
8823        }
8824        return Some(ExistingPortableEntry {
8825            install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
8826            install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
8827            subkey_name: name,
8828        });
8829    }
8830    None
8831}
8832
8833#[cfg(windows)]
8834fn resolve_portable_install_location(
8835    request: &InstallRequest,
8836    existing: &Option<ExistingPortableEntry>,
8837    subkey_name: &str,
8838) -> PathBuf {
8839    resolve_portable_install_location_pure(
8840        request.install_location.as_deref(),
8841        existing
8842            .as_ref()
8843            .and_then(|e| e.install_location.as_deref())
8844            .and_then(Path::to_str),
8845        subkey_name,
8846        &default_user_portable_root(),
8847    )
8848}
8849
8850/// Pure resolution rule for the portable install directory. Priority:
8851/// `request_location` (CLI override) → `existing_location` (so upgrades
8852/// preserve a winget-installed location) → default user portable root joined
8853/// with the package's ARP subkey name.
8854#[cfg(windows)]
8855fn resolve_portable_install_location_pure(
8856    request_location: Option<&str>,
8857    existing_location: Option<&str>,
8858    subkey_name: &str,
8859    default_user_portable_root: &Path,
8860) -> PathBuf {
8861    if let Some(custom) = request_location
8862        && !custom.is_empty()
8863    {
8864        return PathBuf::from(custom);
8865    }
8866    if let Some(loc) = existing_location
8867        && !loc.is_empty()
8868    {
8869        return PathBuf::from(loc);
8870    }
8871    default_user_portable_root.join(subkey_name)
8872}
8873
8874#[cfg(windows)]
8875fn default_user_portable_root() -> PathBuf {
8876    dirs::data_local_dir()
8877        .unwrap_or_else(|| PathBuf::from("."))
8878        .join("Microsoft")
8879        .join("WinGet")
8880        .join("Packages")
8881}
8882
8883#[cfg(windows)]
8884fn clean_directory_contents(dir: &Path) -> Result<()> {
8885    if !dir.is_dir() {
8886        return Ok(());
8887    }
8888    for entry in fs::read_dir(dir)? {
8889        let entry = entry?;
8890        let path = entry.path();
8891        if path.is_dir() {
8892            fs::remove_dir_all(&path)?;
8893        } else {
8894            fs::remove_file(&path)?;
8895        }
8896    }
8897    Ok(())
8898}
8899
8900/// Writes the HKCU ARP entry winget would normally write for a portable
8901/// install. Only the subset that winget's portable list view and pinget's
8902/// `collect_uninstall_view` actually read.
8903#[cfg(windows)]
8904fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
8905    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8906    let uninstall_path = format!(
8907        r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
8908        entry.subkey_name
8909    );
8910    let (subkey, _) = hkcu
8911        .create_subkey(&uninstall_path)
8912        .context("failed to create portable ARP registry subkey")?;
8913
8914    let manifest = entry.manifest;
8915    let install_location_str = entry.install_location.to_string_lossy().to_string();
8916    subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
8917    subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
8918    subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
8919    subkey.set_value("InstallLocation", &install_location_str)?;
8920    subkey.set_value(
8921        "PortableTargetFullPath",
8922        &entry.portable_target_full_path.to_string_lossy().to_string(),
8923    )?;
8924    if let Some(symlink_path) = entry.portable_symlink_full_path {
8925        subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
8926    }
8927    subkey.set_value(
8928        "InstallDirectoryAddedToPath",
8929        &(if entry.added_to_path { 1u32 } else { 0u32 }),
8930    )?;
8931    subkey.set_value(
8932        "InstallDirectoryCreated",
8933        &(if entry.install_directory_created { 1u32 } else { 0u32 }),
8934    )?;
8935    subkey.set_value(
8936        "DisplayName",
8937        &if manifest.name.is_empty() {
8938            manifest.id.clone()
8939        } else {
8940            manifest.name.clone()
8941        },
8942    )?;
8943    subkey.set_value("DisplayVersion", &manifest.version)?;
8944    if let Some(publisher) = manifest.publisher.as_ref() {
8945        subkey.set_value("Publisher", publisher)?;
8946    }
8947    subkey.set_value(
8948        "UninstallString",
8949        &format!("winget uninstall --product-code {}", entry.subkey_name),
8950    )?;
8951    let today = Utc::now().format("%Y%m%d").to_string();
8952    subkey.set_value("InstallDate", &today)?;
8953    if let Some(url) = manifest.package_url.as_ref() {
8954        subkey.set_value("URLInfoAbout", url)?;
8955    }
8956
8957    Ok(())
8958}
8959
8960#[cfg(test)]
8961mod tests {
8962    use std::io::{Read as _, Write};
8963    use std::net::{SocketAddr, TcpListener, TcpStream};
8964    use std::path::{Path, PathBuf};
8965    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
8966    use std::thread::{self, JoinHandle};
8967
8968    use flate2::Compression;
8969    use flate2::write::DeflateEncoder;
8970    use zip::write::SimpleFileOptions;
8971
8972    use super::*;
8973
8974    fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
8975        Manifest {
8976            id: id.to_owned(),
8977            name: name.to_owned(),
8978            version: version.to_owned(),
8979            channel: String::new(),
8980            publisher: Some("Acme".to_owned()),
8981            description: Some("desc".to_owned()),
8982            moniker: None,
8983            package_url: None,
8984            publisher_url: None,
8985            publisher_support_url: None,
8986            license: None,
8987            license_url: None,
8988            privacy_url: None,
8989            author: None,
8990            copyright: None,
8991            copyright_url: None,
8992            release_notes: None,
8993            release_notes_url: None,
8994            tags: Vec::new(),
8995            agreements: Vec::new(),
8996            package_dependencies: Vec::new(),
8997            documentation: Vec::new(),
8998            installers: Vec::new(),
8999            require_explicit_upgrade: false,
9000        }
9001    }
9002
9003    #[test]
9004    fn derive_download_filename_matches_winget_pattern() {
9005        let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
9006        let installer = Installer {
9007            architecture: Some("arm64".to_owned()),
9008            installer_type: Some("exe".to_owned()),
9009            scope: Some("user".to_owned()),
9010            locale: Some("en-US".to_owned()),
9011            ..Installer::default()
9012        };
9013        assert_eq!(
9014            derive_download_manifest_filename(&manifest, &installer),
9015            "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
9016        );
9017    }
9018
9019    #[test]
9020    fn derive_download_filename_omits_optional_components() {
9021        let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
9022        let installer = Installer {
9023            architecture: Some("x64".to_owned()),
9024            installer_type: Some("exe".to_owned()),
9025            ..Installer::default()
9026        };
9027        // No scope, no locale → both omitted; architecture remains PascalCase.
9028        assert_eq!(
9029            derive_download_manifest_filename(&manifest, &installer),
9030            "7-Zip_26.01_X64_exe.yaml"
9031        );
9032    }
9033
9034    #[test]
9035    fn derive_download_filename_skips_unknown_version() {
9036        let manifest = sample_manifest("Test.Package", "Unknown", "Test");
9037        let installer = Installer {
9038            architecture: Some("neutral".to_owned()),
9039            installer_type: Some("msi".to_owned()),
9040            ..Installer::default()
9041        };
9042        assert_eq!(
9043            derive_download_manifest_filename(&manifest, &installer),
9044            "Test_Neutral_msi.yaml"
9045        );
9046    }
9047
9048    #[test]
9049    fn derive_download_filename_sanitizes_illegal_chars() {
9050        let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
9051        let installer = Installer {
9052            architecture: Some("x64".to_owned()),
9053            installer_type: Some("exe".to_owned()),
9054            ..Installer::default()
9055        };
9056        // `/` and `:` are illegal in Windows filenames; both must be replaced.
9057        let filename = derive_download_manifest_filename(&manifest, &installer);
9058        assert!(!filename.contains('/') && !filename.contains(':'));
9059        assert!(filename.starts_with("Some_Bad_Name_1.0"));
9060    }
9061
9062    #[test]
9063    fn build_download_manifest_yaml_overrides_identity_fields() {
9064        let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9065        // Documents contain a stale PackageVersion (e.g. empty) — the typed
9066        // manifest's corrected version must win.
9067        let documents = serde_json::json!({
9068            "PackageIdentifier": "stale-id",
9069            "PackageVersion": "",
9070            "PackageName": "Old Name",
9071            "Channel": "preview",
9072            "Installers": [
9073                { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
9074                { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
9075            ],
9076        });
9077        let installer = Installer {
9078            architecture: Some("x64".to_owned()),
9079            installer_type: Some("exe".to_owned()),
9080            url: Some("https://example.com/a".to_owned()),
9081            ..Installer::default()
9082        };
9083        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9084        assert!(yaml.contains("PackageIdentifier: App.Id"));
9085        assert!(yaml.contains("PackageVersion: 2.0.0"));
9086        assert!(yaml.contains("PackageName: App Name"));
9087        assert!(yaml.contains("ManifestType: merged"));
9088        // Channel had a value in docs but the typed manifest's channel is empty
9089        // → expect it stripped, since winget download manifests don't carry it.
9090        assert!(!yaml.contains("Channel: preview"));
9091        // Installers array narrows to the single selected installer.
9092        assert_eq!(yaml.matches("InstallerUrl").count(), 1);
9093        assert!(yaml.contains("https://example.com/a"));
9094        assert!(!yaml.contains("https://example.com/b"));
9095    }
9096
9097    #[test]
9098    fn build_download_manifest_yaml_preserves_typed_channel() {
9099        let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9100        manifest.channel = "beta".to_owned();
9101        let documents = serde_json::json!({
9102            "PackageIdentifier": "App.Id",
9103            "PackageVersion": "2.0.0",
9104            "Installers": [],
9105        });
9106        let installer = Installer::default();
9107        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9108        assert!(yaml.contains("Channel: beta"));
9109    }
9110
9111    fn temp_app_root(label: &str) -> PathBuf {
9112        static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
9113        std::env::temp_dir().join(format!(
9114            "pinget-rs-tests-{label}-{}-{}",
9115            std::process::id(),
9116            TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
9117        ))
9118    }
9119
9120    #[cfg(windows)]
9121    #[test]
9122    fn path_prefix_check_rejects_string_prefix_collisions() {
9123        // Regression: a naive str::starts_with would falsely consider
9124        // C:\Foobar a child of C:\Foo and let the Links\ sweep delete unrelated
9125        // symlinks. The component-aware check must reject these.
9126        assert!(!path_starts_with_case_insensitive(
9127            Path::new(r"C:\Foobar\bin.exe"),
9128            Path::new(r"C:\Foo"),
9129        ));
9130        assert!(!path_starts_with_case_insensitive(
9131            Path::new(r"C:\Foo\bin.exe.bak"),
9132            Path::new(r"C:\Foo\bin.exe"),
9133        ));
9134    }
9135
9136    #[cfg(windows)]
9137    #[test]
9138    fn path_prefix_check_accepts_real_children_and_self() {
9139        // Dir install: the symlink target lives inside the install dir.
9140        assert!(path_starts_with_case_insensitive(
9141            Path::new(r"C:\Foo\sub\bin.exe"),
9142            Path::new(r"C:\Foo"),
9143        ));
9144        // File install: the symlink points at the same file we removed.
9145        assert!(path_starts_with_case_insensitive(
9146            Path::new(r"C:\Foo\bin.exe"),
9147            Path::new(r"C:\Foo\bin.exe"),
9148        ));
9149    }
9150
9151    #[cfg(windows)]
9152    #[test]
9153    fn path_prefix_check_is_case_insensitive() {
9154        // Windows paths are case-insensitive — winget and pinget may write
9155        // them with different casing.
9156        assert!(path_starts_with_case_insensitive(
9157            Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
9158            Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
9159        ));
9160    }
9161
9162    #[cfg(windows)]
9163    #[test]
9164    fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
9165        let query = PackageQuery {
9166            source: Some("winget".to_owned()),
9167            ..PackageQuery::default()
9168        };
9169        assert_eq!(
9170            portable_source_identifier(&query),
9171            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9172        );
9173        // Case-insensitive — manifests/catalog may report differently.
9174        let query = PackageQuery {
9175            source: Some("WINGET".to_owned()),
9176            ..PackageQuery::default()
9177        };
9178        assert_eq!(
9179            portable_source_identifier(&query),
9180            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9181        );
9182    }
9183
9184    #[cfg(windows)]
9185    #[test]
9186    fn portable_source_identifier_passes_through_custom_sources() {
9187        let query = PackageQuery {
9188            source: Some("internal-feed".to_owned()),
9189            ..PackageQuery::default()
9190        };
9191        assert_eq!(portable_source_identifier(&query), "internal-feed");
9192    }
9193
9194    #[cfg(windows)]
9195    #[test]
9196    fn portable_source_identifier_defaults_for_missing_or_empty_source() {
9197        let query = PackageQuery {
9198            source: None,
9199            ..PackageQuery::default()
9200        };
9201        assert_eq!(
9202            portable_source_identifier(&query),
9203            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9204        );
9205        let query = PackageQuery {
9206            source: Some(String::new()),
9207            ..PackageQuery::default()
9208        };
9209        assert_eq!(
9210            portable_source_identifier(&query),
9211            "Microsoft.Winget.Source_8wekyb3d8bbwe"
9212        );
9213    }
9214
9215    #[cfg(windows)]
9216    #[test]
9217    fn portable_subkey_name_matches_winget_format() {
9218        assert_eq!(
9219            portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
9220            "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
9221        );
9222    }
9223
9224    #[cfg(windows)]
9225    #[test]
9226    fn determine_portable_alias_prefers_nested_installer_alias() {
9227        let installer = Installer {
9228            nested_installer_files: vec![NestedInstallerFile {
9229                relative_file_path: "ripgrep/rg.exe".to_owned(),
9230                portable_command_alias: Some("rg".to_owned()),
9231            }],
9232            commands: vec!["other-name".to_owned()],
9233            ..Installer::default()
9234        };
9235        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
9236    }
9237
9238    #[cfg(windows)]
9239    #[test]
9240    fn determine_portable_alias_falls_back_to_commands() {
9241        let installer = Installer {
9242            // No nested files at all (standalone portable case).
9243            commands: vec!["nuget".to_owned()],
9244            ..Installer::default()
9245        };
9246        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
9247
9248        // Nested files exist but none declare PortableCommandAlias.
9249        let installer = Installer {
9250            nested_installer_files: vec![NestedInstallerFile {
9251                relative_file_path: "subdir/bin.exe".to_owned(),
9252                portable_command_alias: None,
9253            }],
9254            commands: vec!["bin".to_owned()],
9255            ..Installer::default()
9256        };
9257        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
9258    }
9259
9260    #[cfg(windows)]
9261    #[test]
9262    fn determine_portable_alias_returns_none_without_alias_or_commands() {
9263        let installer = Installer::default();
9264        assert!(determine_portable_alias(&installer).is_none());
9265    }
9266
9267    #[cfg(windows)]
9268    #[test]
9269    fn resolve_portable_install_location_prefers_request_override() {
9270        let result = resolve_portable_install_location_pure(
9271            Some(r"D:\Tools\rg"),
9272            Some(r"C:\Software\rg-test"),
9273            "Sub_Source",
9274            Path::new(r"C:\default"),
9275        );
9276        assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
9277    }
9278
9279    #[cfg(windows)]
9280    #[test]
9281    fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
9282        // The #4823 scenario: pinget upgrade with no explicit --location must
9283        // resolve to the install_location the prior install recorded, not the
9284        // default portable root.
9285        let result = resolve_portable_install_location_pure(
9286            None,
9287            Some(r"C:\Software\rg-test"),
9288            "Sub_Source",
9289            Path::new(r"C:\default"),
9290        );
9291        assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
9292    }
9293
9294    #[cfg(windows)]
9295    #[test]
9296    fn resolve_portable_install_location_falls_back_to_default_root() {
9297        let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
9298        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9299    }
9300
9301    #[cfg(windows)]
9302    #[test]
9303    fn resolve_portable_install_location_treats_empty_strings_as_unset() {
9304        // Defensive: PackageQuery / registry reads can yield "" instead of
9305        // None for missing values.
9306        let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
9307        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9308    }
9309
9310    #[cfg(windows)]
9311    #[test]
9312    fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
9313        let root = std::env::temp_dir().join(format!(
9314            "pinget-clean-test-{}",
9315            Utc::now().timestamp_nanos_opt().unwrap_or_default()
9316        ));
9317        fs::create_dir_all(&root).expect("create temp dir");
9318        fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
9319        fs::create_dir_all(root.join("nested")).expect("create nested dir");
9320        fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
9321
9322        clean_directory_contents(&root).expect("clean root contents");
9323
9324        assert!(root.is_dir(), "root dir itself must survive");
9325        assert_eq!(
9326            fs::read_dir(&root).expect("read root").count(),
9327            0,
9328            "root must be empty after clean"
9329        );
9330        let _ = fs::remove_dir_all(&root);
9331    }
9332
9333    #[cfg(windows)]
9334    #[test]
9335    fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
9336        let bogus = std::env::temp_dir().join(format!(
9337            "pinget-clean-test-bogus-{}",
9338            Utc::now().timestamp_nanos_opt().unwrap_or_default()
9339        ));
9340        assert!(!bogus.exists());
9341        clean_directory_contents(&bogus).expect("clean nonexistent dir");
9342        // Should not have created the dir.
9343        assert!(!bogus.exists());
9344    }
9345
9346    #[cfg(windows)]
9347    #[test]
9348    fn path_prefix_check_handles_mixed_separators() {
9349        // NestedInstallerFiles RelativeFilePath uses '/' in manifests, so the
9350        // path we joined can end up mixed-separator on Windows.
9351        assert!(path_starts_with_case_insensitive(
9352            Path::new(r"C:\Foo\sub/bin.exe"),
9353            Path::new(r"C:\Foo"),
9354        ));
9355    }
9356
9357    #[test]
9358    fn repository_options_capture_custom_host_settings() {
9359        let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
9360            .with_user_agent("pinget-rs-tests/1.0")
9361            .with_pre_indexed_source_auto_update_interval(None);
9362
9363        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
9364        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
9365        assert!(options.pre_indexed_source_auto_update_interval.is_none());
9366    }
9367
9368    #[test]
9369    fn dependency_install_requests_are_silent_and_not_forced() {
9370        let mut parent = InstallRequest::new(PackageQuery {
9371            source: Some("winget".to_owned()),
9372            ..PackageQuery::default()
9373        });
9374        parent.mode = InstallerMode::SilentWithProgress;
9375        parent.force = true;
9376        parent.accept_package_agreements = true;
9377        parent.ignore_security_hash = true;
9378        parent.dependency_source = Some("dependencies".to_owned());
9379
9380        let dependency =
9381            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
9382
9383        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
9384        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
9385        assert_eq!(dependency.mode, InstallerMode::Silent);
9386        assert!(!dependency.force);
9387        assert!(dependency.no_upgrade);
9388        assert!(dependency.accept_package_agreements);
9389        assert!(dependency.ignore_security_hash);
9390    }
9391
9392    #[test]
9393    fn storage_paths_use_configured_app_root() {
9394        let app_root = PathBuf::from(r"C:\temp\pinget-test");
9395        let source = SourceRecord {
9396            name: "winget/test".to_owned(),
9397            kind: SourceKind::PreIndexed,
9398            arg: "https://example.com/cache".to_owned(),
9399            identifier: "Test.Source".to_owned(),
9400            trust_level: "None".to_owned(),
9401            explicit: false,
9402            priority: 0,
9403            last_update: None,
9404            source_version: None,
9405        };
9406
9407        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
9408        assert_eq!(
9409            source_state_dir(&app_root, &source),
9410            app_root.join("sources").join("winget_test")
9411        );
9412        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
9413    }
9414
9415    #[cfg(windows)]
9416    #[test]
9417    fn packaged_layout_paths_resolve_from_packaged_app_root() {
9418        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
9419        let app_root = local_app_data
9420            .join("Packages")
9421            .join(PACKAGED_FAMILY_NAME)
9422            .join("LocalState");
9423        assert!(uses_packaged_layout(&app_root));
9424        assert!(
9425            user_settings_path(&app_root)
9426                .display()
9427                .to_string()
9428                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
9429        );
9430        assert!(
9431            packaged_file_cache_root(&app_root)
9432                .display()
9433                .to_string()
9434                .ends_with(&format!(
9435                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
9436                    PACKAGED_FAMILY_NAME
9437                ))
9438        );
9439    }
9440
9441    #[cfg(windows)]
9442    #[test]
9443    fn default_app_root_outside_package_avoids_packaged_layout() {
9444        // Tests run without AppX package identity, so the default app root must not
9445        // resolve to the WinGet packaged LocalState — that location requires a
9446        // brokered/elevated writer for its secure-settings stream.
9447        let prior = std::env::var_os("PINGET_APPROOT");
9448        // SAFETY: tests in this module are not run concurrently with other env mutators.
9449        unsafe { std::env::remove_var("PINGET_APPROOT") };
9450
9451        let result = default_app_root();
9452
9453        if let Some(prior) = prior {
9454            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
9455            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
9456        }
9457
9458        let app_root = result.expect("default app root");
9459        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
9460        assert!(
9461            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
9462            "unexpected fallback app root: {app_root:?}"
9463        );
9464    }
9465
9466    #[test]
9467    fn packaged_source_stream_overlays_defaults_and_metadata() {
9468        let store = parse_packaged_source_store(
9469            Some(
9470                "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",
9471            ),
9472            Some(
9473                "Sources:\n  - Name: corp\n    LastUpdate: 1700000000\n    SourceVersion: 1.2.3\n",
9474            ),
9475        )
9476        .expect("packaged store");
9477
9478        assert!(!store.sources.iter().any(|source| source.name == "winget"));
9479        let corp = store
9480            .sources
9481            .iter()
9482            .find(|source| source.name == "corp")
9483            .expect("corp source");
9484        assert_eq!(corp.kind, SourceKind::Rest);
9485        assert_eq!(corp.identifier, "Contoso.Rest");
9486        assert_eq!(corp.trust_level, "Trusted");
9487        assert!(corp.explicit);
9488        assert_eq!(corp.priority, 7);
9489        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
9490        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
9491    }
9492
9493    #[test]
9494    fn system_winget_export_parses_json_lines() {
9495        let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9496{"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"}
9497"#;
9498
9499        let sources = parse_system_winget_source_export(output).expect("source export");
9500
9501        assert_eq!(
9502            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
9503            vec!["contoso", "contoso-cache"]
9504        );
9505        assert_eq!(sources[0].kind, SourceKind::Rest);
9506        assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
9507        assert_eq!(sources[0].identifier, "api.contoso.test");
9508        assert_eq!(sources[0].trust_level, "Trusted");
9509        assert!(!sources[0].explicit);
9510        assert_eq!(sources[1].kind, SourceKind::PreIndexed);
9511        assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
9512        assert!(sources[1].explicit);
9513    }
9514
9515    #[test]
9516    fn system_winget_export_parses_wrapped_sources() {
9517        let output = r#"{
9518  "Sources": [
9519    {
9520      "Name": "corp",
9521      "Type": "Microsoft.Rest",
9522      "Arg": "https://packages.contoso.test/api",
9523      "Identifier": "Contoso.Rest",
9524      "TrustLevel": "Trusted",
9525      "Explicit": true,
9526      "Priority": 9
9527    }
9528  ]
9529}"#;
9530
9531        let sources = parse_system_winget_source_export(output).expect("source export");
9532        let source = sources.first().expect("source");
9533        assert_eq!(sources.len(), 1);
9534        assert_eq!(source.name, "corp");
9535        assert_eq!(source.kind, SourceKind::Rest);
9536        assert_eq!(source.identifier, "Contoso.Rest");
9537        assert_eq!(source.trust_level, "Trusted");
9538        assert!(source.explicit);
9539        assert_eq!(source.priority, 9);
9540    }
9541
9542    #[test]
9543    fn system_winget_source_store_detects_secure_settings_stub() {
9544        assert!(is_secure_settings_stub(Some(
9545            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
9546        )));
9547        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
9548    }
9549
9550    #[test]
9551    fn system_winget_source_store_builds_add_arguments() {
9552        let args = build_winget_source_add_arguments(
9553            "corp",
9554            "https://packages.contoso.test/api",
9555            SourceKind::Rest,
9556            "Trusted",
9557            true,
9558        );
9559
9560        assert_eq!(
9561            args,
9562            vec![
9563                "source",
9564                "add",
9565                "--name",
9566                "corp",
9567                "--arg",
9568                "https://packages.contoso.test/api",
9569                "--type",
9570                "Microsoft.Rest",
9571                "--disable-interactivity",
9572                "--trust-level",
9573                "trusted",
9574                "--explicit",
9575            ]
9576        );
9577    }
9578
9579    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
9580        Ok(WingetSourceCommandResult {
9581            exit_code: 0,
9582            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9583"#
9584            .to_owned(),
9585            stderr: String::new(),
9586        })
9587    }
9588
9589    #[test]
9590    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
9591        let original_runner = {
9592            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9593            let original = *runner;
9594            *runner = fake_system_winget_source_export;
9595            original
9596        };
9597
9598        let result = load_packaged_store_from_streams(
9599            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
9600            None,
9601        );
9602
9603        {
9604            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9605            *runner = original_runner;
9606        }
9607
9608        let store = result.expect("packaged store").expect("packaged store");
9609        let source = store.sources.first().expect("source");
9610        assert_eq!(store.sources.len(), 1);
9611        assert_eq!(source.name, "contoso");
9612        assert_eq!(source.kind, SourceKind::Rest);
9613    }
9614
9615    #[test]
9616    fn source_metadata_and_named_reset_round_trip() {
9617        let app_root = temp_app_root("source-reset");
9618        let result = (|| -> Result<()> {
9619            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9620            repository.add_source_with_metadata(
9621                "test",
9622                "https://example.com/test",
9623                SourceKind::Rest,
9624                Some("trusted"),
9625                true,
9626                4,
9627            )?;
9628
9629            repository.edit_source("test", Some(false), None)?;
9630            let edited = repository
9631                .list_sources()
9632                .into_iter()
9633                .find(|source| source.name == "test")
9634                .expect("source");
9635            assert_eq!(edited.trust_level, "Trusted");
9636            assert!(!edited.explicit);
9637            assert_eq!(edited.priority, 4);
9638
9639            repository.reset_source("test")?;
9640            let reset = repository
9641                .list_sources()
9642                .into_iter()
9643                .find(|source| source.name == "test")
9644                .expect("source");
9645            assert_eq!(reset.trust_level, "Trusted");
9646            assert!(!reset.explicit);
9647            assert_eq!(reset.priority, 4);
9648            assert!(reset.last_update.is_none());
9649            assert!(reset.source_version.is_none());
9650            Ok(())
9651        })();
9652
9653        let _ = fs::remove_dir_all(&app_root);
9654        result.expect("source metadata");
9655    }
9656
9657    #[test]
9658    fn explicit_sources_are_skipped_by_default_search_resolution() {
9659        let app_root = temp_app_root("source-resolution");
9660        let result = (|| -> Result<()> {
9661            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9662            repository.add_source_with_metadata(
9663                "explicit-test",
9664                "https://example.com/explicit",
9665                SourceKind::Rest,
9666                Some("trusted"),
9667                true,
9668                10,
9669            )?;
9670
9671            let indexes = repository.resolve_source_indexes(None)?;
9672            let names: Vec<_> = indexes
9673                .into_iter()
9674                .map(|index| repository.list_sources()[index].name.clone())
9675                .collect();
9676            assert!(!names.iter().any(|name| name == "explicit-test"));
9677            Ok(())
9678        })();
9679
9680        let _ = fs::remove_dir_all(&app_root);
9681        result.expect("source resolution");
9682    }
9683
9684    #[test]
9685    fn user_and_admin_settings_round_trip() {
9686        let app_root = temp_app_root("settings");
9687        let result = (|| -> Result<()> {
9688            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9689            repository.set_user_settings(
9690                &serde_json::json!({
9691                    "visual": {
9692                        "progressBar": "retro"
9693                    }
9694                }),
9695                false,
9696            )?;
9697            repository.set_user_settings(
9698                &serde_json::json!({
9699                    "experimentalFeatures": {
9700                        "directMSI": true
9701                    }
9702                }),
9703                true,
9704            )?;
9705
9706            let user_settings = repository.get_user_settings()?;
9707            assert_eq!(
9708                user_settings["visual"]["progressBar"],
9709                serde_json::Value::String("retro".to_owned())
9710            );
9711            assert_eq!(
9712                user_settings["experimentalFeatures"]["directMSI"],
9713                serde_json::Value::Bool(true)
9714            );
9715            assert!(repository.test_user_settings(
9716                &serde_json::json!({
9717                    "experimentalFeatures": {
9718                        "directMSI": true
9719                    }
9720                }),
9721                true
9722            )?);
9723
9724            repository.set_admin_setting("LocalManifestFiles", true)?;
9725            repository.set_admin_setting("InstallerHashOverride", true)?;
9726            let admin_settings = repository.get_admin_settings()?;
9727            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
9728            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
9729
9730            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
9731            let reset_one = repository.get_admin_settings()?;
9732            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
9733
9734            repository.reset_admin_setting(None, true)?;
9735            let reset_all = repository.get_admin_settings()?;
9736            for name in Repository::supported_admin_settings() {
9737                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
9738            }
9739            Ok(())
9740        })();
9741
9742        let _ = fs::remove_dir_all(&app_root);
9743        result.expect("settings");
9744    }
9745
9746    #[test]
9747    fn show_result_structured_document_is_manifest_oriented() {
9748        let result = ShowResult {
9749            package: SearchMatch {
9750                source_name: "winget".to_owned(),
9751                source_kind: SourceKind::PreIndexed,
9752                id: "Test.Package".to_owned(),
9753                name: "Test Package".to_owned(),
9754                moniker: Some("testpkg".to_owned()),
9755                version: Some("1.2.3".to_owned()),
9756                channel: Some("stable".to_owned()),
9757                match_criteria: Some("Id".to_owned()),
9758            },
9759            manifest: Manifest {
9760                id: "Test.Package".to_owned(),
9761                name: "Test Package".to_owned(),
9762                version: "1.2.3".to_owned(),
9763                channel: "stable".to_owned(),
9764                publisher: Some("Contoso".to_owned()),
9765                description: Some("Structured output".to_owned()),
9766                moniker: Some("testpkg".to_owned()),
9767                package_url: None,
9768                publisher_url: None,
9769                publisher_support_url: None,
9770                license: None,
9771                license_url: None,
9772                privacy_url: None,
9773                author: None,
9774                copyright: None,
9775                copyright_url: None,
9776                release_notes: None,
9777                release_notes_url: None,
9778                tags: vec!["utility".to_owned()],
9779                agreements: Vec::new(),
9780                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
9781                documentation: vec![Documentation {
9782                    label: Some("Docs".to_owned()),
9783                    url: "https://example.test/docs".to_owned(),
9784                }],
9785                installers: vec![Installer {
9786                    architecture: Some("x64".to_owned()),
9787                    installer_type: Some("msix".to_owned()),
9788                    url: Some("https://example.test/Test.Package.msix".to_owned()),
9789                    sha256: Some("ABC123".to_owned()),
9790                    product_code: None,
9791                    locale: Some("en-US".to_owned()),
9792                    scope: Some("machine".to_owned()),
9793                    release_date: None,
9794                    package_family_name: None,
9795                    upgrade_code: None,
9796                    platforms: Vec::new(),
9797                    minimum_os_version: None,
9798                    switches: InstallerSwitches {
9799                        silent: Some("/quiet".to_owned()),
9800                        ..InstallerSwitches::default()
9801                    },
9802                    commands: vec!["testpkg".to_owned()],
9803                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9804                    require_explicit_upgrade: false,
9805                    nested_installer_type: None,
9806                    nested_installer_files: Vec::new(),
9807                }],
9808                require_explicit_upgrade: false,
9809            },
9810            selected_installer: Some(Installer {
9811                architecture: Some("x64".to_owned()),
9812                installer_type: Some("msix".to_owned()),
9813                url: Some("https://example.test/Test.Package.msix".to_owned()),
9814                sha256: Some("ABC123".to_owned()),
9815                product_code: None,
9816                locale: Some("en-US".to_owned()),
9817                scope: Some("machine".to_owned()),
9818                release_date: None,
9819                package_family_name: None,
9820                upgrade_code: None,
9821                platforms: Vec::new(),
9822                minimum_os_version: None,
9823                switches: InstallerSwitches {
9824                    silent: Some("/quiet".to_owned()),
9825                    ..InstallerSwitches::default()
9826                },
9827                commands: vec!["testpkg".to_owned()],
9828                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9829                require_explicit_upgrade: false,
9830                nested_installer_type: None,
9831                nested_installer_files: Vec::new(),
9832            }),
9833            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
9834            warnings: vec!["cache warmed".to_owned()],
9835            manifest_documents: JsonValue::Array(vec![
9836                serde_json::json!({
9837                    "PackageIdentifier": "Test.Package",
9838                    "PackageVersion": "1.2.3",
9839                    "DefaultLocale": "en-US",
9840                    "ManifestType": "version",
9841                    "ManifestVersion": "1.10.0"
9842                }),
9843                serde_json::json!({
9844                    "PackageIdentifier": "Test.Package",
9845                    "PackageVersion": "1.2.3",
9846                    "PackageLocale": "en-US",
9847                    "PackageName": "Test Package",
9848                    "Publisher": "Example",
9849                    "License": "MIT",
9850                    "ShortDescription": "Structured output",
9851                    "ManifestType": "defaultLocale",
9852                    "ManifestVersion": "1.10.0"
9853                }),
9854                serde_json::json!({
9855                    "PackageIdentifier": "Test.Package",
9856                    "PackageVersion": "1.2.3",
9857                    "ManifestType": "installer",
9858                    "ManifestVersion": "1.10.0",
9859                    "Installers": [
9860                        {
9861                            "Architecture": "x64",
9862                            "InstallerType": "msix",
9863                            "InstallerUrl": "https://example.test/Test.Package.msix",
9864                            "InstallerSha256": "ABC123",
9865                            "Commands": ["testpkg"],
9866                            "InstallerSwitches": { "Silent": "/quiet" },
9867                            "Dependencies": {
9868                                "PackageDependencies": [
9869                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
9870                                ]
9871                            }
9872                        }
9873                    ]
9874                }),
9875            ]),
9876        };
9877
9878        let document = result.structured_document();
9879
9880        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
9881        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
9882        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
9883        assert_eq!(
9884            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
9885            Some("Microsoft.VCRedist.2015+.x64")
9886        );
9887        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
9888        assert_eq!(
9889            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
9890            Some("/quiet")
9891        );
9892    }
9893
9894    #[test]
9895    fn manifest_parses_require_explicit_upgrade_at_top_level() {
9896        // Top-level RequireExplicitUpgrade flag should propagate to
9897        // Manifest.require_explicit_upgrade. winget catalogs typically
9898        // place the flag here for browser packages and similar
9899        // self-updating apps that opt out of bulk `upgrade`.
9900        let yaml = r#"
9901PackageIdentifier: Test.Package
9902PackageVersion: 1.2.3
9903DefaultLocale: en-US
9904ManifestType: singleton
9905ManifestVersion: 1.10.0
9906PackageLocale: en-US
9907PackageName: Test Package
9908Publisher: Example
9909License: MIT
9910ShortDescription: explicit-upgrade fixture
9911RequireExplicitUpgrade: true
9912Installers:
9913  - Architecture: x64
9914    InstallerType: exe
9915    InstallerUrl: https://example.test/Test.Package.exe
9916    InstallerSha256: ABC123
9917"#;
9918        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9919        assert!(manifest.require_explicit_upgrade);
9920    }
9921
9922    #[test]
9923    fn manifest_parses_require_explicit_upgrade_on_installer() {
9924        // Per-installer flag — only one of several installers declares it,
9925        // but the Manifest aggregate must still be `true` because the user
9926        // could pick that installer when upgrading.
9927        let yaml = r#"
9928PackageIdentifier: Test.Package
9929PackageVersion: 1.2.3
9930DefaultLocale: en-US
9931ManifestType: singleton
9932ManifestVersion: 1.10.0
9933PackageLocale: en-US
9934PackageName: Test Package
9935Publisher: Example
9936License: MIT
9937ShortDescription: explicit-upgrade fixture
9938Installers:
9939  - Architecture: x64
9940    InstallerType: exe
9941    InstallerUrl: https://example.test/Test.Package.x64.exe
9942    InstallerSha256: ABC123
9943  - Architecture: arm64
9944    InstallerType: exe
9945    InstallerUrl: https://example.test/Test.Package.arm64.exe
9946    InstallerSha256: DEF456
9947    RequireExplicitUpgrade: true
9948"#;
9949        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9950        assert!(manifest.require_explicit_upgrade);
9951        assert!(!manifest.installers[0].require_explicit_upgrade);
9952        assert!(manifest.installers[1].require_explicit_upgrade);
9953    }
9954
9955    #[test]
9956    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
9957        let yaml = r#"
9958PackageIdentifier: Test.Package
9959PackageVersion: 1.2.3
9960DefaultLocale: en-US
9961ManifestType: singleton
9962ManifestVersion: 1.10.0
9963PackageLocale: en-US
9964PackageName: Test Package
9965Publisher: Example
9966License: MIT
9967ShortDescription: baseline fixture
9968Installers:
9969  - Architecture: x64
9970    InstallerType: exe
9971    InstallerUrl: https://example.test/Test.Package.exe
9972    InstallerSha256: ABC123
9973"#;
9974        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9975        assert!(!manifest.require_explicit_upgrade);
9976    }
9977
9978    #[test]
9979    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
9980        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
9981        // (Edge, Steam, Discord). pinget must do the same — the filter is
9982        // the only gate that enforces it.
9983        let mut pkg = InstalledPackage {
9984            name: "Edge".to_owned(),
9985            local_id: r"ARP\Machine\X64\Edge".to_owned(),
9986            installed_version: "100.0".to_owned(),
9987            publisher: None,
9988            scope: Some("Machine".to_owned()),
9989            installer_category: Some("exe".to_owned()),
9990            install_location: None,
9991            package_family_names: Vec::new(),
9992            product_codes: Vec::new(),
9993            upgrade_codes: Vec::new(),
9994            correlated: Some(SearchMatch {
9995                source_name: "winget".to_owned(),
9996                source_kind: SourceKind::PreIndexed,
9997                id: "Microsoft.Edge".to_owned(),
9998                name: "Microsoft Edge".to_owned(),
9999                moniker: None,
10000                version: Some("110.0".to_owned()),
10001                channel: None,
10002                match_criteria: None,
10003            }),
10004            installed_version_canonical: false,
10005            correlated_requires_explicit_upgrade: true,
10006            correlated_lacks_compatible_installer: false,
10007        };
10008        let bulk_query = ListQuery {
10009            upgrade_only: true,
10010            ..ListQuery::default()
10011        };
10012        assert!(
10013            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
10014            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
10015        );
10016
10017        // When the user explicitly targets it by id, winget shows it —
10018        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
10019        let filtered_query = ListQuery {
10020            upgrade_only: true,
10021            id: Some("Microsoft.Edge".to_owned()),
10022            ..ListQuery::default()
10023        };
10024        assert!(
10025            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
10026            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
10027        );
10028
10029        // Without the flag, the row appears in bulk upgrade as usual.
10030        pkg.correlated_requires_explicit_upgrade = false;
10031        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10032    }
10033
10034    #[test]
10035    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
10036        let mut pkg = InstalledPackage {
10037            name: "Foo".to_owned(),
10038            local_id: r"ARP\Machine\X64\Foo".to_owned(),
10039            installed_version: "1.0".to_owned(),
10040            publisher: None,
10041            scope: Some("Machine".to_owned()),
10042            installer_category: Some("exe".to_owned()),
10043            install_location: None,
10044            package_family_names: Vec::new(),
10045            product_codes: Vec::new(),
10046            upgrade_codes: Vec::new(),
10047            correlated: Some(SearchMatch {
10048                source_name: "winget".to_owned(),
10049                source_kind: SourceKind::PreIndexed,
10050                id: "Test.Foo".to_owned(),
10051                name: "Foo".to_owned(),
10052                moniker: None,
10053                version: Some("2.0".to_owned()),
10054                channel: None,
10055                match_criteria: None,
10056            }),
10057            installed_version_canonical: false,
10058            correlated_requires_explicit_upgrade: false,
10059            correlated_lacks_compatible_installer: true,
10060        };
10061        let bulk_query = ListQuery {
10062            upgrade_only: true,
10063            ..ListQuery::default()
10064        };
10065        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10066
10067        // Explicit `--id` still surfaces it.
10068        let filtered_query = ListQuery {
10069            upgrade_only: true,
10070            id: Some("Test.Foo".to_owned()),
10071            ..ListQuery::default()
10072        };
10073        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
10074
10075        // Cleared flag → visible in bulk.
10076        pkg.correlated_lacks_compatible_installer = false;
10077        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10078    }
10079
10080    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
10081        let installers = arches
10082            .iter()
10083            .map(|a| Installer {
10084                architecture: Some((*a).to_owned()),
10085                installer_type: None,
10086                url: None,
10087                sha256: None,
10088                product_code: None,
10089                locale: None,
10090                scope: None,
10091                release_date: None,
10092                package_family_name: None,
10093                upgrade_code: None,
10094                platforms: Vec::new(),
10095                minimum_os_version: None,
10096                switches: InstallerSwitches::default(),
10097                commands: Vec::new(),
10098                package_dependencies: Vec::new(),
10099                require_explicit_upgrade: false,
10100                nested_installer_type: None,
10101                nested_installer_files: Vec::new(),
10102            })
10103            .collect();
10104        Manifest {
10105            id: "Test".to_owned(),
10106            name: "Test".to_owned(),
10107            version: "1.0".to_owned(),
10108            channel: String::new(),
10109            publisher: None,
10110            description: None,
10111            moniker: None,
10112            package_url: None,
10113            publisher_url: None,
10114            publisher_support_url: None,
10115            license: None,
10116            license_url: None,
10117            privacy_url: None,
10118            author: None,
10119            copyright: None,
10120            copyright_url: None,
10121            release_notes: None,
10122            release_notes_url: None,
10123            tags: Vec::new(),
10124            agreements: Vec::new(),
10125            package_dependencies: Vec::new(),
10126            documentation: Vec::new(),
10127            installers,
10128            require_explicit_upgrade: false,
10129        }
10130    }
10131
10132    #[test]
10133    fn manifest_compatible_installer_neutral_always_matches() {
10134        // `neutral` is in every host's allowed list.
10135        assert!(manifest_has_compatible_installer(
10136            &synthetic_manifest_with_installer_arches(&["neutral"])
10137        ));
10138    }
10139
10140    #[test]
10141    fn manifest_compatible_installer_empty_installers_pass_through() {
10142        // Manifest with no installers (e.g. a docs-only entry): nothing to
10143        // filter against, don't hide the row.
10144        assert!(manifest_has_compatible_installer(
10145            &synthetic_manifest_with_installer_arches(&[])
10146        ));
10147    }
10148
10149    #[test]
10150    fn manifest_compatible_installer_rejects_alien_arch_only() {
10151        // `ppc` is in no host's allowed list — manifest has nothing runnable.
10152        assert!(!manifest_has_compatible_installer(
10153            &synthetic_manifest_with_installer_arches(&["ppc"])
10154        ));
10155    }
10156
10157    #[test]
10158    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
10159        // Mixed list where at least `neutral` is in every host's preference.
10160        assert!(manifest_has_compatible_installer(
10161            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
10162        ));
10163    }
10164
10165    #[test]
10166    fn lookup_unique_normalized_identity_returns_unique_match() {
10167        // Single (norm_name, norm_publisher) intersection — happy path.
10168        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10169        execute_batch_sql(
10170            &connection,
10171            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10172             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10173             INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
10174             INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
10175        )
10176        .expect("seed schema");
10177
10178        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
10179            .expect("query")
10180            .expect("unique match");
10181        assert_eq!(rowid, 100);
10182    }
10183
10184    #[test]
10185    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
10186        // Two distinct packages share the same (norm_name, norm_publisher)
10187        // — the Git case where both `Git.Git` and a hypothetical sibling
10188        // normalize identically. winget refuses to correlate when it can't
10189        // disambiguate; pinget must do the same.
10190        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10191        execute_batch_sql(
10192            &connection,
10193            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10194             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10195             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10196             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
10197        )
10198        .expect("seed schema");
10199
10200        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10201        assert!(rowid.is_none(), "ambiguous match must not correlate");
10202    }
10203
10204    #[test]
10205    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
10206        // norm_name has multiple matches but only one shares its
10207        // norm_publisher with the installed package. Winget's intersect
10208        // logic still picks the right one — verify.
10209        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10210        execute_batch_sql(
10211            &connection,
10212            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10213             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10214             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10215             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
10216        )
10217        .expect("seed schema");
10218
10219        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10220        assert_eq!(rowid, Some(100));
10221    }
10222
10223    #[test]
10224    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
10225        // Name matches but no publisher row for that package id —
10226        // intersection is empty so we don't correlate.
10227        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10228        execute_batch_sql(
10229            &connection,
10230            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10231             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10232             INSERT INTO norm_names2 VALUES ('foo', 100);\n\
10233             INSERT INTO norm_publishers2 VALUES ('bar', 200);",
10234        )
10235        .expect("seed schema");
10236
10237        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
10238        assert!(
10239            rowid.is_none(),
10240            "name matches package 100 but publisher matches 200 — no intersection"
10241        );
10242    }
10243
10244    #[test]
10245    fn parse_yaml_manifest_bundle_returns_singleton_document() {
10246        let yaml = r#"
10247PackageIdentifier: Test.Package
10248PackageVersion: 1.2.3
10249DefaultLocale: en-US
10250ManifestType: version
10251ManifestVersion: 1.10.0
10252---
10253PackageIdentifier: Test.Package
10254PackageVersion: 1.2.3
10255PackageLocale: en-US
10256PackageName: Test Package
10257Publisher: Example
10258License: MIT
10259ShortDescription: Structured output
10260ManifestType: defaultLocale
10261ManifestVersion: 1.10.0
10262---
10263PackageIdentifier: Test.Package
10264PackageVersion: 1.2.3
10265ManifestType: installer
10266ManifestVersion: 1.10.0
10267Installers:
10268  - Architecture: x64
10269    InstallerType: exe
10270    InstallerUrl: https://example.test/Test.Package.exe
10271    InstallerSha256: ABC123
10272"#;
10273
10274        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
10275
10276        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
10277        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
10278        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
10279    }
10280
10281    #[test]
10282    fn collapse_structured_documents_returns_plural_show_documents() {
10283        let documents = collapse_structured_documents(&[
10284            JsonValue::Array(vec![
10285                serde_json::json!({
10286                    "PackageIdentifier": "Test.Package.One",
10287                    "PackageVersion": "1.0.0",
10288                    "DefaultLocale": "en-US",
10289                    "ManifestType": "version",
10290                    "ManifestVersion": "1.10.0"
10291                }),
10292                serde_json::json!({
10293                    "PackageIdentifier": "Test.Package.One",
10294                    "PackageVersion": "1.0.0",
10295                    "PackageLocale": "en-US",
10296                    "PackageName": "Test Package One",
10297                    "ManifestType": "defaultLocale",
10298                    "ManifestVersion": "1.10.0"
10299                }),
10300                serde_json::json!({
10301                    "PackageIdentifier": "Test.Package.One",
10302                    "PackageVersion": "1.0.0",
10303                    "ManifestType": "installer",
10304                    "ManifestVersion": "1.10.0",
10305                    "Installers": [
10306                        {
10307                            "Architecture": "x64",
10308                            "InstallerType": "exe",
10309                            "InstallerUrl": "https://example.test/one.exe",
10310                            "InstallerSha256": "ABC123"
10311                        }
10312                    ]
10313                }),
10314            ]),
10315            serde_json::json!({
10316                "PackageIdentifier": "Test.Package.Two",
10317                "PackageVersion": "2.0.0",
10318                "PackageLocale": "en-US",
10319                "PackageName": "Test Package Two",
10320                "ManifestType": "singleton",
10321                "ManifestVersion": "1.12.0"
10322            }),
10323        ]);
10324
10325        assert_eq!(documents.len(), 2);
10326        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
10327        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
10328        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
10329        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
10330        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
10331    }
10332
10333    #[test]
10334    fn unsupported_action_result_marks_noop_and_warning() {
10335        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
10336
10337        assert!(result.success);
10338        assert!(result.no_op);
10339        assert_eq!(result.exit_code, 0);
10340        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
10341    }
10342
10343    #[test]
10344    fn create_install_no_op_result_honors_no_upgrade() {
10345        let mut request = InstallRequest::new(PackageQuery {
10346            id: Some("Contoso.App".to_owned()),
10347            ..PackageQuery::default()
10348        });
10349        request.no_upgrade = true;
10350
10351        let manifest = Manifest {
10352            id: "Contoso.App".to_owned(),
10353            name: "Contoso App".to_owned(),
10354            version: "2.0.0".to_owned(),
10355            channel: String::new(),
10356            publisher: None,
10357            description: None,
10358            moniker: None,
10359            package_url: None,
10360            publisher_url: None,
10361            publisher_support_url: None,
10362            license: None,
10363            license_url: None,
10364            privacy_url: None,
10365            author: None,
10366            copyright: None,
10367            copyright_url: None,
10368            release_notes: None,
10369            release_notes_url: None,
10370            tags: Vec::new(),
10371            agreements: Vec::new(),
10372            package_dependencies: Vec::new(),
10373            documentation: Vec::new(),
10374            installers: Vec::new(),
10375            require_explicit_upgrade: false,
10376        };
10377        let existing = ListMatch {
10378            name: "Contoso App".to_owned(),
10379            id: "Contoso.App".to_owned(),
10380            local_id: "Contoso.App".to_owned(),
10381            installed_version: "1.0.0".to_owned(),
10382            available_version: Some("2.0.0".to_owned()),
10383            source_name: Some("winget".to_owned()),
10384            publisher: None,
10385            scope: None,
10386            installer_category: None,
10387            install_location: None,
10388            package_family_names: Vec::new(),
10389            product_codes: Vec::new(),
10390            upgrade_codes: Vec::new(),
10391        };
10392
10393        let result =
10394            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10395
10396        assert!(result.success);
10397        assert!(result.no_op);
10398        assert_eq!(result.version, "1.0.0");
10399        assert_eq!(
10400            result.warnings,
10401            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
10402        );
10403    }
10404
10405    #[test]
10406    fn create_install_no_op_result_skips_reinstall_when_current() {
10407        let request = InstallRequest::new(PackageQuery {
10408            id: Some("Contoso.App".to_owned()),
10409            ..PackageQuery::default()
10410        });
10411
10412        let manifest = Manifest {
10413            id: "Contoso.App".to_owned(),
10414            name: "Contoso App".to_owned(),
10415            version: "2.0.0".to_owned(),
10416            channel: String::new(),
10417            publisher: None,
10418            description: None,
10419            moniker: None,
10420            package_url: None,
10421            publisher_url: None,
10422            publisher_support_url: None,
10423            license: None,
10424            license_url: None,
10425            privacy_url: None,
10426            author: None,
10427            copyright: None,
10428            copyright_url: None,
10429            release_notes: None,
10430            release_notes_url: None,
10431            tags: Vec::new(),
10432            agreements: Vec::new(),
10433            package_dependencies: Vec::new(),
10434            documentation: Vec::new(),
10435            installers: Vec::new(),
10436            require_explicit_upgrade: false,
10437        };
10438        let existing = ListMatch {
10439            name: "Contoso App".to_owned(),
10440            id: "Contoso.App".to_owned(),
10441            local_id: "Contoso.App".to_owned(),
10442            installed_version: "2.0.0".to_owned(),
10443            available_version: Some("2.0.0".to_owned()),
10444            source_name: Some("winget".to_owned()),
10445            publisher: None,
10446            scope: None,
10447            installer_category: None,
10448            install_location: None,
10449            package_family_names: Vec::new(),
10450            product_codes: Vec::new(),
10451            upgrade_codes: Vec::new(),
10452        };
10453
10454        let result =
10455            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10456
10457        assert!(result.success);
10458        assert!(result.no_op);
10459        assert_eq!(result.version, "2.0.0");
10460        assert_eq!(
10461            result.warnings,
10462            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
10463        );
10464    }
10465
10466    #[test]
10467    fn collapse_structured_document_projects_merged_manifest() {
10468        let document = collapse_structured_document(&serde_json::json!({
10469            "PackageIdentifier": "Test.Package",
10470            "PackageVersion": "1.2.3",
10471            "PackageLocale": "en-US",
10472            "PackageName": "Test Package",
10473            "Publisher": "Example",
10474            "InstallerType": "exe",
10475            "Installers": [
10476                {
10477                    "Architecture": "x64",
10478                    "InstallerUrl": "https://example.test/Test.Package.exe",
10479                    "InstallerSha256": "ABC123"
10480                }
10481            ],
10482            "ManifestType": "merged",
10483            "ManifestVersion": "1.10.0"
10484        }));
10485
10486        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
10487        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
10488        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
10489        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
10490        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
10491    }
10492
10493    #[test]
10494    fn parses_fixture_manifest() {
10495        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
10496            .join("tests")
10497            .join("fixtures")
10498            .join("ManifestV1_28-Singleton.yaml");
10499        let bytes = fs::read(root).expect("fixture bytes");
10500        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
10501        assert_eq!(manifest.id, "microsoft.msixsdk");
10502        assert_eq!(manifest.version, "1.7.32");
10503        assert_eq!(manifest.name, "MSIX SDK");
10504        assert!(!manifest.installers.is_empty());
10505    }
10506
10507    #[test]
10508    fn parses_installer_switches_from_yaml_manifest() {
10509        let yaml = r#"
10510PackageIdentifier: Test.Package
10511PackageVersion: 1.2.3
10512PackageName: Test Package
10513InstallerSwitches:
10514  SilentWithProgress: /SILENT
10515Installers:
10516  - Architecture: x64
10517    InstallerType: inno
10518    InstallerUrl: https://example.test/Test.Package.exe
10519    InstallerSha256: ABC123
10520    InstallerSwitches:
10521      Silent: /VERYSILENT
10522      Interactive: /HELP
10523"#;
10524
10525        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10526        let installer = &manifest.installers[0];
10527
10528        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
10529        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
10530        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
10531    }
10532
10533    #[test]
10534    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
10535        let yaml = r#"
10536PackageIdentifier: Test.Package
10537PackageVersion: 1.2.3
10538PackageName: Test Package
10539Platform:
10540  - Windows.Desktop
10541MinimumOSVersion: 10.0.19041.0
10542Installers:
10543  - Architecture: x64
10544    InstallerType: exe
10545    InstallerUrl: https://example.test/Test.Package.exe
10546    InstallerSha256: ABC123
10547  - Architecture: x64
10548    InstallerType: msix
10549    Platform:
10550      - Windows.Universal
10551    MinimumOSVersion: 10.0.22621.0
10552    InstallerUrl: https://example.test/Test.Package.msix
10553    InstallerSha256: DEF456
10554"#;
10555
10556        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10557
10558        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
10559        assert_eq!(
10560            manifest.installers[0].minimum_os_version.as_deref(),
10561            Some("10.0.19041.0")
10562        );
10563        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
10564        assert_eq!(
10565            manifest.installers[1].minimum_os_version.as_deref(),
10566            Some("10.0.22621.0")
10567        );
10568    }
10569
10570    #[test]
10571    fn installer_switch_arguments_prefer_manifest_switches() {
10572        let installer = Installer {
10573            architecture: None,
10574            installer_type: Some("inno".to_owned()),
10575            url: None,
10576            sha256: None,
10577            product_code: None,
10578            locale: None,
10579            scope: None,
10580            release_date: None,
10581            package_family_name: None,
10582            upgrade_code: None,
10583            platforms: Vec::new(),
10584            minimum_os_version: None,
10585            switches: InstallerSwitches {
10586                silent: Some("/mysilent".to_owned()),
10587                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
10588                interactive: Some("/myinteractive".to_owned()),
10589                ..InstallerSwitches::default()
10590            },
10591            commands: Vec::new(),
10592            package_dependencies: Vec::new(),
10593            require_explicit_upgrade: false,
10594            nested_installer_type: None,
10595            nested_installer_files: Vec::new(),
10596        };
10597        let manifest = Manifest {
10598            id: "Test.Package".to_owned(),
10599            name: "Test Package".to_owned(),
10600            version: "1.0.0".to_owned(),
10601            channel: String::new(),
10602            publisher: None,
10603            description: None,
10604            moniker: None,
10605            package_url: None,
10606            publisher_url: None,
10607            publisher_support_url: None,
10608            license: None,
10609            license_url: None,
10610            privacy_url: None,
10611            author: None,
10612            copyright: None,
10613            copyright_url: None,
10614            release_notes: None,
10615            release_notes_url: None,
10616            tags: Vec::new(),
10617            agreements: Vec::new(),
10618            package_dependencies: Vec::new(),
10619            documentation: Vec::new(),
10620            installers: Vec::new(),
10621            require_explicit_upgrade: false,
10622        };
10623
10624        let mut silent_request = InstallRequest::new(PackageQuery::default());
10625        silent_request.mode = InstallerMode::Silent;
10626        let mut progress_request = InstallRequest::new(PackageQuery::default());
10627        progress_request.mode = InstallerMode::SilentWithProgress;
10628        let mut interactive_request = InstallRequest::new(PackageQuery::default());
10629        interactive_request.mode = InstallerMode::Interactive;
10630        assert_eq!(
10631            installer_command_arguments(
10632                "inno",
10633                &silent_request,
10634                &manifest,
10635                Path::new("installer.exe"),
10636                &installer
10637            ),
10638            vec!["/mysilent".to_owned()]
10639        );
10640        assert_eq!(
10641            installer_command_arguments(
10642                "inno",
10643                &progress_request,
10644                &manifest,
10645                Path::new("installer.exe"),
10646                &installer
10647            ),
10648            vec!["/mysilentwithprogress".to_owned()]
10649        );
10650        assert_eq!(
10651            installer_command_arguments(
10652                "inno",
10653                &interactive_request,
10654                &manifest,
10655                Path::new("installer.exe"),
10656                &installer
10657            ),
10658            vec!["/myinteractive".to_owned()]
10659        );
10660    }
10661
10662    #[test]
10663    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
10664        let installer = Installer {
10665            architecture: None,
10666            installer_type: Some("inno".to_owned()),
10667            url: None,
10668            sha256: None,
10669            product_code: None,
10670            locale: None,
10671            scope: None,
10672            release_date: None,
10673            package_family_name: None,
10674            upgrade_code: None,
10675            platforms: Vec::new(),
10676            minimum_os_version: None,
10677            switches: InstallerSwitches::default(),
10678            commands: Vec::new(),
10679            package_dependencies: Vec::new(),
10680            require_explicit_upgrade: false,
10681            nested_installer_type: None,
10682            nested_installer_files: Vec::new(),
10683        };
10684        let manifest = Manifest {
10685            id: "Test.Package".to_owned(),
10686            name: "Test Package".to_owned(),
10687            version: "1.0.0".to_owned(),
10688            channel: String::new(),
10689            publisher: None,
10690            description: None,
10691            moniker: None,
10692            package_url: None,
10693            publisher_url: None,
10694            publisher_support_url: None,
10695            license: None,
10696            license_url: None,
10697            privacy_url: None,
10698            author: None,
10699            copyright: None,
10700            copyright_url: None,
10701            release_notes: None,
10702            release_notes_url: None,
10703            tags: Vec::new(),
10704            agreements: Vec::new(),
10705            package_dependencies: Vec::new(),
10706            documentation: Vec::new(),
10707            installers: Vec::new(),
10708            require_explicit_upgrade: false,
10709        };
10710        let mut progress_request = InstallRequest::new(PackageQuery::default());
10711        progress_request.mode = InstallerMode::SilentWithProgress;
10712        let mut silent_request = InstallRequest::new(PackageQuery::default());
10713        silent_request.mode = InstallerMode::Silent;
10714
10715        assert_eq!(
10716            installer_command_arguments(
10717                "inno",
10718                &progress_request,
10719                &manifest,
10720                Path::new("installer.exe"),
10721                &installer
10722            ),
10723            vec![
10724                "/SP-".to_owned(),
10725                "/SILENT".to_owned(),
10726                "/SUPPRESSMSGBOXES".to_owned(),
10727                "/NORESTART".to_owned()
10728            ]
10729        );
10730        assert_eq!(
10731            installer_command_arguments(
10732                "inno",
10733                &silent_request,
10734                &manifest,
10735                Path::new("installer.exe"),
10736                &installer
10737            ),
10738            vec![
10739                "/SP-".to_owned(),
10740                "/VERYSILENT".to_owned(),
10741                "/SUPPRESSMSGBOXES".to_owned(),
10742                "/NORESTART".to_owned()
10743            ]
10744        );
10745    }
10746
10747    #[test]
10748    fn installer_command_arguments_append_manifest_and_cli_switches() {
10749        let installer = Installer {
10750            architecture: None,
10751            installer_type: Some("msi".to_owned()),
10752            url: None,
10753            sha256: None,
10754            product_code: None,
10755            locale: None,
10756            scope: None,
10757            release_date: None,
10758            package_family_name: None,
10759            upgrade_code: None,
10760            platforms: Vec::new(),
10761            minimum_os_version: None,
10762            switches: InstallerSwitches {
10763                custom: Some("ADDLOCAL=Core".to_owned()),
10764                log: Some("/log \"<LOGPATH>\"".to_owned()),
10765                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
10766                ..InstallerSwitches::default()
10767            },
10768            commands: Vec::new(),
10769            package_dependencies: Vec::new(),
10770            require_explicit_upgrade: false,
10771            nested_installer_type: None,
10772            nested_installer_files: Vec::new(),
10773        };
10774        let manifest = Manifest {
10775            id: "ShareX.ShareX".to_owned(),
10776            name: "ShareX".to_owned(),
10777            version: "19.0.2".to_owned(),
10778            channel: String::new(),
10779            publisher: None,
10780            description: None,
10781            moniker: None,
10782            package_url: None,
10783            publisher_url: None,
10784            publisher_support_url: None,
10785            license: None,
10786            license_url: None,
10787            privacy_url: None,
10788            author: None,
10789            copyright: None,
10790            copyright_url: None,
10791            release_notes: None,
10792            release_notes_url: None,
10793            tags: Vec::new(),
10794            agreements: Vec::new(),
10795            package_dependencies: Vec::new(),
10796            documentation: Vec::new(),
10797            installers: Vec::new(),
10798            require_explicit_upgrade: false,
10799        };
10800        let mut request = InstallRequest::new(PackageQuery::default());
10801        request.mode = InstallerMode::Silent;
10802        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
10803        request.custom = Some("REBOOT=ReallySuppress".to_owned());
10804        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
10805
10806        assert_eq!(
10807            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
10808            vec![
10809                "/i".to_owned(),
10810                r"C:\temp\ShareX.msi".to_owned(),
10811                "/quiet".to_owned(),
10812                "/norestart".to_owned(),
10813                "/log".to_owned(),
10814                r"C:\temp\winget.log".to_owned(),
10815                "ADDLOCAL=Core".to_owned(),
10816                "REBOOT=ReallySuppress".to_owned(),
10817                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
10818            ]
10819        );
10820    }
10821
10822    #[test]
10823    fn decompresses_mszyml_payload() {
10824        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
10825        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10826        encoder.write_all(payload.as_bytes()).expect("write payload");
10827        let compressed = encoder.finish().expect("finish payload");
10828        let mut mszip = b"CK".to_vec();
10829        mszip.extend_from_slice(&compressed);
10830
10831        let decompressed = decompress_mszyml(&mszip).expect("decompress");
10832        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10833        assert_eq!(parsed.versions[0].version, "1.2.3");
10834    }
10835
10836    #[test]
10837    fn decompresses_mszyml_payload_with_prefix() {
10838        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
10839        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10840        encoder.write_all(payload.as_bytes()).expect("write payload");
10841        let compressed = encoder.finish().expect("finish payload");
10842        let mut prefixed = vec![0u8; 28];
10843        prefixed.extend_from_slice(b"CK");
10844        prefixed.extend_from_slice(&compressed);
10845
10846        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
10847        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10848        assert_eq!(parsed.versions[0].version, "1.2.3");
10849    }
10850
10851    #[test]
10852    fn compares_versions_naturally() {
10853        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
10854        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
10855        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
10856    }
10857
10858    #[test]
10859    fn installer_hash_verification_fails_on_mismatch_without_override() {
10860        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
10861        assert!(result.is_err());
10862        assert!(
10863            result
10864                .expect_err("hash mismatch")
10865                .to_string()
10866                .contains("Installer hash mismatch")
10867        );
10868    }
10869
10870    #[test]
10871    fn installer_hash_verification_allows_mismatch_with_override() {
10872        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
10873        verify_installer_hash(None, b"pinget", false).expect("missing hash");
10874    }
10875
10876    #[test]
10877    fn search_query_many_includes_tag_and_command_conditions() {
10878        let query = PackageQuery {
10879            query: Some("terminal".to_owned()),
10880            ..PackageQuery::default()
10881        };
10882
10883        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10884
10885        assert!(where_clause.contains("tags2"));
10886        assert!(where_clause.contains("commands2"));
10887        assert_eq!(params.len(), 5);
10888    }
10889
10890    #[test]
10891    fn tag_and_command_conditions_are_non_correlated() {
10892        // The tag/command map lookups must be emitted as non-correlated
10893        // `rowid IN (SELECT ...)` subqueries. A correlated `EXISTS (... WHERE
10894        // map.owner = packages.rowid ...)` re-scans the un-indexed map tables
10895        // per package row, which is quadratic (~19s on the real winget index
10896        // under SQLite, and an effective hang under Turso). Guard against a
10897        // regression back to the correlated form.
10898        let query = PackageQuery {
10899            query: Some("terminal".to_owned()),
10900            ..PackageQuery::default()
10901        };
10902
10903        let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10904
10905        assert!(where_clause.contains("packages.rowid IN (SELECT"));
10906        assert!(!where_clause.contains("EXISTS"));
10907        assert!(!where_clause.contains("= packages.rowid"));
10908    }
10909
10910    #[test]
10911    fn show_query_single_omits_tag_and_command_conditions() {
10912        let query = PackageQuery {
10913            query: Some("terminal".to_owned()),
10914            ..PackageQuery::default()
10915        };
10916
10917        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
10918
10919        assert!(!where_clause.contains("tags2"));
10920        assert!(!where_clause.contains("commands2"));
10921        assert_eq!(params.len(), 3);
10922    }
10923
10924    #[test]
10925    fn explicit_tag_filter_uses_exact_match() {
10926        let query = PackageQuery {
10927            tag: Some("terminal".to_owned()),
10928            ..PackageQuery::default()
10929        };
10930
10931        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10932
10933        assert!(where_clause.contains("tags2"));
10934        assert_eq!(params, vec!["terminal".to_owned()]);
10935    }
10936
10937    #[test]
10938    fn rest_tag_filter_uses_exact_match() {
10939        let query = PackageQuery {
10940            tag: Some("terminal".to_owned()),
10941            ..PackageQuery::default()
10942        };
10943        let info = RestInformation {
10944            required_package_match_fields: Vec::new(),
10945            unsupported_package_match_fields: Vec::new(),
10946            required_query_parameters: Vec::new(),
10947            ..RestInformation::default()
10948        };
10949
10950        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
10951        let filters = body["Filters"].as_array().expect("filters");
10952
10953        assert_eq!(filters.len(), 1);
10954        assert_eq!(filters[0]["PackageMatchField"], "Tag");
10955        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
10956        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
10957    }
10958
10959    #[test]
10960    fn selects_installer_using_requested_filters() {
10961        let installers = vec![
10962            Installer {
10963                architecture: Some("x86".to_owned()),
10964                installer_type: Some("zip".to_owned()),
10965                url: None,
10966                sha256: None,
10967                product_code: None,
10968                locale: Some("en-US".to_owned()),
10969                scope: Some("user".to_owned()),
10970                release_date: None,
10971                package_family_name: None,
10972                upgrade_code: None,
10973                platforms: Vec::new(),
10974                minimum_os_version: None,
10975                switches: InstallerSwitches::default(),
10976                commands: Vec::new(),
10977                package_dependencies: Vec::new(),
10978                require_explicit_upgrade: false,
10979                nested_installer_type: None,
10980                nested_installer_files: Vec::new(),
10981            },
10982            Installer {
10983                architecture: Some("x64".to_owned()),
10984                installer_type: Some("msix".to_owned()),
10985                url: None,
10986                sha256: None,
10987                product_code: None,
10988                locale: Some("en-US".to_owned()),
10989                scope: Some("user".to_owned()),
10990                release_date: None,
10991                package_family_name: None,
10992                upgrade_code: None,
10993                platforms: Vec::new(),
10994                minimum_os_version: None,
10995                switches: InstallerSwitches::default(),
10996                commands: vec!["demo".to_owned()],
10997                package_dependencies: Vec::new(),
10998                require_explicit_upgrade: false,
10999                nested_installer_type: None,
11000                nested_installer_files: Vec::new(),
11001            },
11002        ];
11003        let query = PackageQuery {
11004            installer_type: Some("msix".to_owned()),
11005            installer_architecture: Some("x64".to_owned()),
11006            install_scope: Some("user".to_owned()),
11007            locale: Some("en-US".to_owned()),
11008            ..PackageQuery::default()
11009        };
11010
11011        let selected = select_installer(&installers, &query).expect("selected installer");
11012        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
11013        assert_eq!(selected.architecture.as_deref(), Some("x64"));
11014    }
11015
11016    #[test]
11017    fn selects_first_installer_when_rank_ties() {
11018        let installers = vec![
11019            Installer {
11020                architecture: Some("x64".to_owned()),
11021                installer_type: Some("exe".to_owned()),
11022                url: None,
11023                sha256: None,
11024                product_code: None,
11025                locale: Some("en-US".to_owned()),
11026                scope: Some("machine".to_owned()),
11027                release_date: None,
11028                package_family_name: None,
11029                upgrade_code: None,
11030                platforms: Vec::new(),
11031                minimum_os_version: None,
11032                switches: InstallerSwitches::default(),
11033                commands: Vec::new(),
11034                package_dependencies: Vec::new(),
11035                require_explicit_upgrade: false,
11036                nested_installer_type: None,
11037                nested_installer_files: Vec::new(),
11038            },
11039            Installer {
11040                architecture: Some("x64".to_owned()),
11041                installer_type: Some("exe".to_owned()),
11042                url: None,
11043                sha256: None,
11044                product_code: None,
11045                locale: Some("en-US".to_owned()),
11046                scope: Some("user".to_owned()),
11047                release_date: None,
11048                package_family_name: None,
11049                upgrade_code: None,
11050                platforms: Vec::new(),
11051                minimum_os_version: None,
11052                switches: InstallerSwitches::default(),
11053                commands: Vec::new(),
11054                package_dependencies: Vec::new(),
11055                require_explicit_upgrade: false,
11056                nested_installer_type: None,
11057                nested_installer_files: Vec::new(),
11058            },
11059        ];
11060
11061        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
11062        assert_eq!(selected.scope.as_deref(), Some("machine"));
11063    }
11064
11065    #[test]
11066    fn selects_installer_using_requested_platform() {
11067        let installers = vec![
11068            Installer {
11069                architecture: Some("x64".to_owned()),
11070                installer_type: Some("exe".to_owned()),
11071                url: None,
11072                sha256: None,
11073                product_code: None,
11074                locale: Some("en-US".to_owned()),
11075                scope: Some("user".to_owned()),
11076                release_date: None,
11077                package_family_name: None,
11078                upgrade_code: None,
11079                platforms: vec!["Windows.Universal".to_owned()],
11080                minimum_os_version: None,
11081                switches: InstallerSwitches::default(),
11082                commands: Vec::new(),
11083                package_dependencies: Vec::new(),
11084                require_explicit_upgrade: false,
11085                nested_installer_type: None,
11086                nested_installer_files: Vec::new(),
11087            },
11088            Installer {
11089                architecture: Some("x64".to_owned()),
11090                installer_type: Some("exe".to_owned()),
11091                url: None,
11092                sha256: None,
11093                product_code: None,
11094                locale: Some("en-US".to_owned()),
11095                scope: Some("user".to_owned()),
11096                release_date: None,
11097                package_family_name: None,
11098                upgrade_code: None,
11099                platforms: vec!["Windows.Desktop".to_owned()],
11100                minimum_os_version: None,
11101                switches: InstallerSwitches::default(),
11102                commands: Vec::new(),
11103                package_dependencies: Vec::new(),
11104                require_explicit_upgrade: false,
11105                nested_installer_type: None,
11106                nested_installer_files: Vec::new(),
11107            },
11108        ];
11109        let query = PackageQuery {
11110            installer_type: Some("exe".to_owned()),
11111            platform: Some("Windows.Desktop".to_owned()),
11112            ..PackageQuery::default()
11113        };
11114
11115        let selected = select_installer(&installers, &query).expect("selected installer");
11116        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
11117    }
11118
11119    #[test]
11120    fn selects_installer_using_requested_os_version() {
11121        let installers = vec![
11122            Installer {
11123                architecture: Some("x64".to_owned()),
11124                installer_type: Some("exe".to_owned()),
11125                url: None,
11126                sha256: None,
11127                product_code: None,
11128                locale: Some("en-US".to_owned()),
11129                scope: Some("user".to_owned()),
11130                release_date: None,
11131                package_family_name: None,
11132                upgrade_code: None,
11133                platforms: Vec::new(),
11134                minimum_os_version: Some("10.0.22621.0".to_owned()),
11135                switches: InstallerSwitches::default(),
11136                commands: Vec::new(),
11137                package_dependencies: Vec::new(),
11138                require_explicit_upgrade: false,
11139                nested_installer_type: None,
11140                nested_installer_files: Vec::new(),
11141            },
11142            Installer {
11143                architecture: Some("x64".to_owned()),
11144                installer_type: Some("exe".to_owned()),
11145                url: None,
11146                sha256: None,
11147                product_code: None,
11148                locale: Some("en-US".to_owned()),
11149                scope: Some("user".to_owned()),
11150                release_date: None,
11151                package_family_name: None,
11152                upgrade_code: None,
11153                platforms: Vec::new(),
11154                minimum_os_version: Some("10.0.19041.0".to_owned()),
11155                switches: InstallerSwitches::default(),
11156                commands: Vec::new(),
11157                package_dependencies: Vec::new(),
11158                require_explicit_upgrade: false,
11159                nested_installer_type: None,
11160                nested_installer_files: Vec::new(),
11161            },
11162        ];
11163        let query = PackageQuery {
11164            installer_type: Some("exe".to_owned()),
11165            os_version: Some("10.0.19045.0".to_owned()),
11166            ..PackageQuery::default()
11167        };
11168
11169        let selected = select_installer(&installers, &query).expect("selected installer");
11170        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
11171    }
11172
11173    #[test]
11174    fn derives_correlation_name_candidates() {
11175        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
11176        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
11177        assert!(candidates.contains(&"PowerToys".to_owned()));
11178    }
11179
11180    #[test]
11181    fn correlates_installed_package_to_available_match() {
11182        let installed = InstalledPackage {
11183            name: "PowerToys (Preview) x64".to_owned(),
11184            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11185            installed_version: "0.98.1".to_owned(),
11186            publisher: None,
11187            scope: Some("Machine".to_owned()),
11188            installer_category: Some("exe".to_owned()),
11189            install_location: None,
11190            package_family_names: Vec::new(),
11191            product_codes: Vec::new(),
11192            upgrade_codes: Vec::new(),
11193            correlated: None,
11194            installed_version_canonical: false,
11195            correlated_requires_explicit_upgrade: false,
11196            correlated_lacks_compatible_installer: false,
11197        };
11198        let candidates = vec![SearchMatch {
11199            source_name: "winget".to_owned(),
11200            source_kind: SourceKind::PreIndexed,
11201            id: "Microsoft.PowerToys".to_owned(),
11202            name: "PowerToys".to_owned(),
11203            moniker: None,
11204            version: Some("0.98.1".to_owned()),
11205            channel: None,
11206            match_criteria: None,
11207        }];
11208
11209        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11210        assert_eq!(correlated.id, "Microsoft.PowerToys");
11211    }
11212
11213    #[test]
11214    fn list_query_uses_available_lookup_for_tag_filters() {
11215        let query = ListQuery {
11216            tag: Some("terminal".to_owned()),
11217            ..ListQuery::default()
11218        };
11219
11220        assert!(list_query_needs_available_lookup(&query));
11221    }
11222
11223    #[test]
11224    fn list_tag_filter_requires_correlated_match() {
11225        let package = InstalledPackage {
11226            name: "PowerToys (Preview) x64".to_owned(),
11227            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11228            installed_version: "0.98.1".to_owned(),
11229            publisher: None,
11230            scope: Some("Machine".to_owned()),
11231            installer_category: Some("exe".to_owned()),
11232            install_location: None,
11233            package_family_names: Vec::new(),
11234            product_codes: Vec::new(),
11235            upgrade_codes: Vec::new(),
11236            correlated: None,
11237            installed_version_canonical: false,
11238            correlated_requires_explicit_upgrade: false,
11239            correlated_lacks_compatible_installer: false,
11240        };
11241        let query = ListQuery {
11242            tag: Some("powertoys".to_owned()),
11243            ..ListQuery::default()
11244        };
11245
11246        assert!(!list_package_matches(&package, &query));
11247    }
11248
11249    #[test]
11250    fn list_lookup_ignores_display_count() {
11251        let query = ListQuery {
11252            query: Some("git".to_owned()),
11253            count: Some(5),
11254            ..ListQuery::default()
11255        };
11256
11257        let package_query = package_query_from_list_query(&query);
11258        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
11259    }
11260
11261    #[test]
11262    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
11263        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
11264        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
11265        // whichever candidate happened to come last in iteration order. The installed
11266        // display name's prefix decides the correct one.
11267        let installed = InstalledPackage {
11268            name: "Notepad++ (ARM 64-bit)".to_owned(),
11269            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
11270            installed_version: "8.8.9".to_owned(),
11271            publisher: None,
11272            scope: Some("Machine".to_owned()),
11273            installer_category: Some("exe".to_owned()),
11274            install_location: None,
11275            package_family_names: Vec::new(),
11276            product_codes: Vec::new(),
11277            upgrade_codes: Vec::new(),
11278            correlated: None,
11279            installed_version_canonical: false,
11280            correlated_requires_explicit_upgrade: false,
11281            correlated_lacks_compatible_installer: false,
11282        };
11283        let candidates = vec![
11284            SearchMatch {
11285                source_name: "winget".to_owned(),
11286                source_kind: SourceKind::PreIndexed,
11287                id: "ndd.Notepad--".to_owned(),
11288                name: "Notepad--".to_owned(),
11289                moniker: None,
11290                version: Some("1.0.0".to_owned()),
11291                channel: None,
11292                match_criteria: None,
11293            },
11294            SearchMatch {
11295                source_name: "winget".to_owned(),
11296                source_kind: SourceKind::PreIndexed,
11297                id: "Notepad++.Notepad++".to_owned(),
11298                name: "Notepad++".to_owned(),
11299                moniker: None,
11300                version: Some("8.9.5".to_owned()),
11301                channel: None,
11302                match_criteria: None,
11303            },
11304        ];
11305
11306        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11307        assert_eq!(correlated.id, "Notepad++.Notepad++");
11308    }
11309
11310    #[test]
11311    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
11312        // Without the prefix bonus, the loose substring match picked up `Studio`
11313        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
11314        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
11315        // sort order favored the alphabetically-later candidate.
11316        let installed = InstalledPackage {
11317            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
11318            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
11319            installed_version: "40.10.18029".to_owned(),
11320            publisher: None,
11321            scope: Some("Machine".to_owned()),
11322            installer_category: Some("msi".to_owned()),
11323            install_location: None,
11324            package_family_names: Vec::new(),
11325            product_codes: Vec::new(),
11326            upgrade_codes: Vec::new(),
11327            correlated: None,
11328            installed_version_canonical: false,
11329            correlated_requires_explicit_upgrade: false,
11330            correlated_lacks_compatible_installer: false,
11331        };
11332        let candidates = vec![
11333            SearchMatch {
11334                source_name: "winget".to_owned(),
11335                source_kind: SourceKind::PreIndexed,
11336                id: "ZeroBrane.Studio".to_owned(),
11337                name: "Studio".to_owned(),
11338                moniker: None,
11339                version: Some("1.0".to_owned()),
11340                channel: None,
11341                match_criteria: None,
11342            },
11343            SearchMatch {
11344                source_name: "winget".to_owned(),
11345                source_kind: SourceKind::PreIndexed,
11346                id: "BrickLink.Studio".to_owned(),
11347                name: "Studio".to_owned(),
11348                moniker: None,
11349                version: Some("1.0".to_owned()),
11350                channel: None,
11351                match_criteria: None,
11352            },
11353            SearchMatch {
11354                source_name: "winget".to_owned(),
11355                source_kind: SourceKind::PreIndexed,
11356                id: "Microsoft.DotNet.SDK.10".to_owned(),
11357                name: "Microsoft .NET SDK 10.0".to_owned(),
11358                moniker: None,
11359                version: Some("10.0.204".to_owned()),
11360                channel: None,
11361                match_criteria: None,
11362            },
11363        ];
11364
11365        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11366        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
11367    }
11368
11369    #[test]
11370    fn msix_packages_do_not_correlate_via_name() {
11371        // MSIX correlation must go through the v2 index's `pfns2` table — name
11372        // fallback is wrong because two MSIX packages can legitimately share a
11373        // display name without sharing identity (`Microsoft Edge Stable` MSIX
11374        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
11375        // the catalog Inno installer). Even when the names match exactly the
11376        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
11377        // PFN hit; that lookup happens in `correlate_installed_via_index`.
11378        let installed = InstalledPackage {
11379            name: "Microsoft Teams".to_owned(),
11380            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
11381            installed_version: "25290.205.4069.4894".to_owned(),
11382            publisher: None,
11383            scope: Some("User".to_owned()),
11384            installer_category: Some("msix".to_owned()),
11385            install_location: None,
11386            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
11387            product_codes: Vec::new(),
11388            upgrade_codes: Vec::new(),
11389            correlated: None,
11390            installed_version_canonical: false,
11391            correlated_requires_explicit_upgrade: false,
11392            correlated_lacks_compatible_installer: false,
11393        };
11394        let candidates = vec![SearchMatch {
11395            source_name: "winget".to_owned(),
11396            source_kind: SourceKind::PreIndexed,
11397            id: "Microsoft.Teams".to_owned(),
11398            name: "Microsoft Teams".to_owned(),
11399            moniker: None,
11400            version: Some("26106.1906.4665.7308".to_owned()),
11401            channel: None,
11402            match_criteria: None,
11403        }];
11404
11405        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11406    }
11407
11408    #[test]
11409    fn name_fallback_refuses_ambiguous_winners() {
11410        // The user has Git installed (publisher "The Git Development Community"
11411        // — but publisher isn't in the scoring function). Two catalog packages
11412        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
11413        // publisher disambiguation they score identically; winget refuses to
11414        // correlate (the install lists with empty Source). pinget must do the
11415        // same to avoid manufacturing an upgrade against the wrong catalog
11416        // package.
11417        let installed = InstalledPackage {
11418            name: "Git".to_owned(),
11419            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
11420            installed_version: "2.53.0".to_owned(),
11421            publisher: Some("The Git Development Community".to_owned()),
11422            scope: Some("Machine".to_owned()),
11423            installer_category: Some("exe".to_owned()),
11424            install_location: None,
11425            package_family_names: Vec::new(),
11426            product_codes: Vec::new(),
11427            upgrade_codes: Vec::new(),
11428            correlated: None,
11429            installed_version_canonical: false,
11430            correlated_requires_explicit_upgrade: false,
11431            correlated_lacks_compatible_installer: false,
11432        };
11433        let candidates = vec![
11434            SearchMatch {
11435                source_name: "winget".to_owned(),
11436                source_kind: SourceKind::PreIndexed,
11437                id: "Git.Git".to_owned(),
11438                name: "Git".to_owned(),
11439                moniker: None,
11440                version: Some("2.54.0".to_owned()),
11441                channel: None,
11442                match_criteria: None,
11443            },
11444            SearchMatch {
11445                source_name: "winget".to_owned(),
11446                source_kind: SourceKind::PreIndexed,
11447                id: "Microsoft.Git".to_owned(),
11448                name: "Git".to_owned(),
11449                moniker: None,
11450                version: Some("2.53.0.0.7".to_owned()),
11451                channel: None,
11452                match_criteria: None,
11453            },
11454        ];
11455
11456        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11457    }
11458
11459    #[test]
11460    fn msix_with_resource_string_name_does_not_correlate() {
11461        // Some MSIX entries have unresolved resource-string display names
11462        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
11463        // unrelated catalog package via the loose substring rule.
11464        let installed = InstalledPackage {
11465            name: "ms-resource:appDisplayName".to_owned(),
11466            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
11467            installed_version: "1.28.239.0".to_owned(),
11468            publisher: None,
11469            scope: Some("User".to_owned()),
11470            installer_category: Some("msix".to_owned()),
11471            install_location: None,
11472            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
11473            product_codes: Vec::new(),
11474            upgrade_codes: Vec::new(),
11475            correlated: None,
11476            installed_version_canonical: false,
11477            correlated_requires_explicit_upgrade: false,
11478            correlated_lacks_compatible_installer: false,
11479        };
11480        let candidates = vec![SearchMatch {
11481            source_name: "winget".to_owned(),
11482            source_kind: SourceKind::PreIndexed,
11483            id: "Microsoft.AppInstaller".to_owned(),
11484            name: "App Installer".to_owned(),
11485            moniker: None,
11486            version: Some("1.28.240.0".to_owned()),
11487            channel: None,
11488            match_criteria: None,
11489        }];
11490
11491        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11492    }
11493
11494    #[test]
11495    fn strict_list_correlation_avoids_short_substring_matches() {
11496        let installed = InstalledPackage {
11497            name: "GitHub".to_owned(),
11498            local_id: r"ARP\User\X64\GitHub".to_owned(),
11499            installed_version: "1.0.0".to_owned(),
11500            publisher: None,
11501            scope: Some("User".to_owned()),
11502            installer_category: Some("exe".to_owned()),
11503            install_location: None,
11504            package_family_names: Vec::new(),
11505            product_codes: Vec::new(),
11506            upgrade_codes: Vec::new(),
11507            correlated: None,
11508            installed_version_canonical: false,
11509            correlated_requires_explicit_upgrade: false,
11510            correlated_lacks_compatible_installer: false,
11511        };
11512        let candidates = vec![SearchMatch {
11513            source_name: "winget".to_owned(),
11514            source_kind: SourceKind::PreIndexed,
11515            id: "Git.Git.PreRelease".to_owned(),
11516            name: "Git".to_owned(),
11517            moniker: None,
11518            version: Some("2.54.0".to_owned()),
11519            channel: None,
11520            match_criteria: None,
11521        }];
11522
11523        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
11524    }
11525
11526    #[test]
11527    fn detects_available_upgrade_from_correlated_package() {
11528        let package = InstalledPackage {
11529            name: "AzCopy v10".to_owned(),
11530            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
11531            installed_version: "10.32.2".to_owned(),
11532            publisher: None,
11533            scope: Some("Machine".to_owned()),
11534            installer_category: Some("exe".to_owned()),
11535            install_location: None,
11536            package_family_names: Vec::new(),
11537            product_codes: Vec::new(),
11538            upgrade_codes: Vec::new(),
11539            correlated: Some(SearchMatch {
11540                source_name: "winget".to_owned(),
11541                source_kind: SourceKind::PreIndexed,
11542                id: "Microsoft.Azure.AZCopy.10".to_owned(),
11543                name: "AzCopy v10".to_owned(),
11544                moniker: None,
11545                version: Some("10.32.3".to_owned()),
11546                channel: None,
11547                match_criteria: None,
11548            }),
11549            installed_version_canonical: false,
11550            correlated_requires_explicit_upgrade: false,
11551            correlated_lacks_compatible_installer: false,
11552        };
11553
11554        assert!(installed_package_has_upgrade(&package));
11555    }
11556
11557    #[test]
11558    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
11559        let package = InstalledPackage {
11560            name: "Example Tool".to_owned(),
11561            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
11562            installed_version: "Unknown".to_owned(),
11563            publisher: None,
11564            scope: Some("User".to_owned()),
11565            installer_category: Some("exe".to_owned()),
11566            install_location: None,
11567            package_family_names: Vec::new(),
11568            product_codes: Vec::new(),
11569            upgrade_codes: Vec::new(),
11570            correlated: Some(SearchMatch {
11571                source_name: "winget".to_owned(),
11572                source_kind: SourceKind::PreIndexed,
11573                id: "Contoso.ExampleTool".to_owned(),
11574                name: "Example Tool".to_owned(),
11575                moniker: None,
11576                version: Some("2.0.0".to_owned()),
11577                channel: None,
11578                match_criteria: None,
11579            }),
11580            installed_version_canonical: false,
11581            correlated_requires_explicit_upgrade: false,
11582            correlated_lacks_compatible_installer: false,
11583        };
11584        let query = ListQuery {
11585            upgrade_only: true,
11586            include_unknown: true,
11587            ..Default::default()
11588        };
11589
11590        assert!(installed_package_matches_upgrade_filter(&package, &query));
11591        assert_eq!(
11592            list_match_from_installed(package).available_version.as_deref(),
11593            Some("2.0.0")
11594        );
11595    }
11596
11597    #[test]
11598    fn create_repair_list_query_includes_installed_selectors() {
11599        let request = RepairRequest {
11600            query: PackageQuery {
11601                query: Some("powertoys".to_owned()),
11602                id: Some("Microsoft.PowerToys".to_owned()),
11603                name: Some("PowerToys".to_owned()),
11604                moniker: Some("powertoys".to_owned()),
11605                source: Some("winget".to_owned()),
11606                exact: true,
11607                version: Some("0.98.1".to_owned()),
11608                install_scope: Some("Machine".to_owned()),
11609                ..PackageQuery::default()
11610            },
11611            manifest_path: None,
11612            product_code: Some("{GUID}".to_owned()),
11613            mode: InstallerMode::Silent,
11614            log_path: Some(PathBuf::from("repair.log")),
11615            accept_package_agreements: true,
11616            force: true,
11617            ignore_security_hash: true,
11618        };
11619
11620        let query = Repository::create_repair_list_query(&request);
11621        assert_eq!(query.query.as_deref(), Some("powertoys"));
11622        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
11623        assert_eq!(query.name.as_deref(), Some("PowerToys"));
11624        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
11625        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
11626        assert_eq!(query.version.as_deref(), Some("0.98.1"));
11627        assert_eq!(query.source.as_deref(), Some("winget"));
11628        assert!(query.exact);
11629        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
11630        assert_eq!(query.count, Some(100));
11631    }
11632
11633    #[test]
11634    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
11635        let request = RepairRequest {
11636            query: PackageQuery {
11637                name: Some("PowerToys".to_owned()),
11638                source: Some("winget".to_owned()),
11639                locale: Some("en-US".to_owned()),
11640                installer_architecture: Some("x64".to_owned()),
11641                install_scope: Some("Machine".to_owned()),
11642                ..PackageQuery::default()
11643            },
11644            manifest_path: None,
11645            product_code: None,
11646            mode: InstallerMode::SilentWithProgress,
11647            log_path: Some(PathBuf::from("repair.log")),
11648            accept_package_agreements: true,
11649            force: false,
11650            ignore_security_hash: true,
11651        };
11652        let installed = ListMatch {
11653            name: "PowerToys".to_owned(),
11654            id: "Microsoft.PowerToys".to_owned(),
11655            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
11656            installed_version: "0.98.1".to_owned(),
11657            available_version: Some("0.98.2".to_owned()),
11658            source_name: Some("winget".to_owned()),
11659            publisher: None,
11660            scope: Some("Machine".to_owned()),
11661            installer_category: Some("exe".to_owned()),
11662            install_location: None,
11663            package_family_names: Vec::new(),
11664            product_codes: Vec::new(),
11665            upgrade_codes: Vec::new(),
11666        };
11667
11668        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
11669        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
11670        assert!(install_request.query.query.is_none());
11671        assert!(install_request.query.name.is_none());
11672        assert!(install_request.query.moniker.is_none());
11673        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
11674        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
11675        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
11676        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
11677        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
11678        assert!(install_request.query.exact);
11679        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
11680        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
11681        assert!(install_request.accept_package_agreements);
11682        assert!(install_request.force);
11683        assert!(install_request.ignore_security_hash);
11684    }
11685
11686    #[test]
11687    fn source_scoped_pin_operations_round_trip() {
11688        let app_root = temp_app_root("pins");
11689        let result = (|| -> Result<()> {
11690            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11691            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
11692            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
11693            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
11694
11695            assert_eq!(repository.list_pins(None)?.len(), 3);
11696            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
11697            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
11698
11699            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
11700            assert_eq!(repository.list_pins(None)?.len(), 2);
11701            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
11702
11703            repository.reset_pins(Some("test"))?;
11704            let remaining = repository.list_pins(None)?;
11705            assert_eq!(remaining.len(), 1);
11706            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
11707
11708            repository.reset_pins(None)?;
11709            assert!(repository.list_pins(None)?.is_empty());
11710            Ok(())
11711        })();
11712
11713        let _ = fs::remove_dir_all(&app_root);
11714        result.expect("pin round trip");
11715    }
11716
11717    #[test]
11718    fn pin_operations_work_with_packaged_pin_schema() {
11719        let app_root = temp_app_root("pins_packaged_schema");
11720        let result = (|| -> Result<()> {
11721            let db_path = pins_db_path(&app_root);
11722            if let Some(parent) = db_path.parent() {
11723                fs::create_dir_all(parent)?;
11724            }
11725
11726            let conn = Repository::open_sqlite_connection(db_path.clone())?;
11727            execute_batch_sql(
11728                &conn,
11729                "CREATE TABLE pin (
11730                    package_id TEXT NOT NULL,
11731                    source_id TEXT NOT NULL,
11732                    type INTEGER NOT NULL,
11733                    version TEXT NOT NULL,
11734                    PRIMARY KEY (package_id, source_id)
11735                )",
11736            )?;
11737            conn.cacheflush().context("failed to flush pin database")?;
11738            drop(conn);
11739
11740            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11741            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
11742
11743            let pins = repository.list_pins(None)?;
11744            assert_eq!(pins.len(), 1);
11745            assert_eq!(pins[0].package_id, "Contoso.Tool");
11746            assert_eq!(pins[0].version, "1.2.*");
11747            assert_eq!(pins[0].source_id, "winget");
11748            assert_eq!(pins[0].pin_type, PinType::Gating);
11749
11750            let conn = Repository::open_sqlite_connection(db_path)?;
11751            let stored_pin = query_optional_value(
11752                &conn,
11753                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
11754                vec![
11755                    SqlValue::Text("Contoso.Tool".to_owned()),
11756                    SqlValue::Text("winget".to_owned()),
11757                ],
11758                |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
11759            )?
11760            .ok_or_else(|| anyhow!("expected stored pin"))?;
11761            assert_eq!(stored_pin.0, 3);
11762            assert_eq!(stored_pin.1, "1.2.*");
11763
11764            Ok(())
11765        })();
11766
11767        let _ = fs::remove_dir_all(&app_root);
11768        result.expect("packaged pin schema round trip");
11769    }
11770
11771    #[test]
11772    fn version_pin_patterns_match_exact_and_prefix_values() {
11773        assert!(version_matches_pin_pattern("1.2.3", "*"));
11774        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
11775        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
11776        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
11777        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
11778    }
11779
11780    #[test]
11781    fn source_specific_pins_override_source_agnostic_pins() {
11782        let item = ListMatch {
11783            name: "Contoso Tool".to_owned(),
11784            id: "Contoso.Tool".to_owned(),
11785            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11786            installed_version: "1.0.0".to_owned(),
11787            available_version: Some("2.0.0".to_owned()),
11788            source_name: Some("winget".to_owned()),
11789            publisher: None,
11790            scope: None,
11791            installer_category: None,
11792            install_location: None,
11793            package_family_names: Vec::new(),
11794            product_codes: Vec::new(),
11795            upgrade_codes: Vec::new(),
11796        };
11797        let pins = vec![
11798            PinRecord {
11799                package_id: "Contoso.Tool".to_owned(),
11800                version: "*".to_owned(),
11801                source_id: String::new(),
11802                pin_type: PinType::Blocking,
11803            },
11804            PinRecord {
11805                package_id: "Contoso.Tool".to_owned(),
11806                version: "2.0.*".to_owned(),
11807                source_id: "winget".to_owned(),
11808                pin_type: PinType::Pinning,
11809            },
11810        ];
11811
11812        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
11813    }
11814
11815    #[test]
11816    fn blocking_pin_blocks_upgrade() {
11817        let item = ListMatch {
11818            name: "Contoso Tool".to_owned(),
11819            id: "Contoso.Tool".to_owned(),
11820            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11821            installed_version: "1.0.0".to_owned(),
11822            available_version: Some("2.0.0".to_owned()),
11823            source_name: Some("winget".to_owned()),
11824            publisher: None,
11825            scope: None,
11826            installer_category: None,
11827            install_location: None,
11828            package_family_names: Vec::new(),
11829            product_codes: Vec::new(),
11830            upgrade_codes: Vec::new(),
11831        };
11832        let pins = vec![PinRecord {
11833            package_id: "Contoso.Tool".to_owned(),
11834            version: "*".to_owned(),
11835            source_id: "winget".to_owned(),
11836            pin_type: PinType::Blocking,
11837        }];
11838
11839        assert!(is_upgrade_blocked_by_pin(&item, &pins));
11840    }
11841
11842    #[cfg(windows)]
11843    #[test]
11844    fn parses_msix_package_full_name_into_version_and_family() {
11845        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
11846            .expect("package metadata");
11847
11848        assert_eq!(parsed.version, "0.98.1.0");
11849        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
11850    }
11851
11852    #[cfg(windows)]
11853    #[test]
11854    fn recognizes_windows_system_paths() {
11855        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
11856        assert!(!is_windows_system_path(
11857            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
11858        ));
11859    }
11860
11861    #[test]
11862    fn search_ranking_prefers_exact_name_over_tag_match() {
11863        let query = PackageQuery {
11864            query: Some("PowerToys".to_owned()),
11865            ..Default::default()
11866        };
11867        let exact_name = SearchMatch {
11868            source_name: "winget".to_owned(),
11869            source_kind: SourceKind::PreIndexed,
11870            id: "Microsoft.PowerToys".to_owned(),
11871            name: "PowerToys".to_owned(),
11872            moniker: Some("powertoys".to_owned()),
11873            version: Some("0.98.1".to_owned()),
11874            channel: None,
11875            match_criteria: Some("Moniker: powertoys".to_owned()),
11876        };
11877        let tag_match = SearchMatch {
11878            source_name: "winget".to_owned(),
11879            source_kind: SourceKind::PreIndexed,
11880            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
11881            name: "QR Codes for Command Palette".to_owned(),
11882            moniker: None,
11883            version: Some("0.4.0.0".to_owned()),
11884            channel: None,
11885            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
11886        };
11887
11888        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
11889    }
11890
11891    #[test]
11892    fn select_best_text_match_prefers_exact_tag_value() {
11893        let values = vec![
11894            "microsoft-powertoys".to_owned(),
11895            "powertoys-run".to_owned(),
11896            "powertoys".to_owned(),
11897        ];
11898
11899        assert_eq!(
11900            select_best_text_match(values, "PowerToys", false).as_deref(),
11901            Some("powertoys")
11902        );
11903    }
11904
11905    #[test]
11906    fn search_match_criteria_is_blank_for_name_match() {
11907        let query = PackageQuery {
11908            query: Some("PowerToys".to_owned()),
11909            ..Default::default()
11910        };
11911
11912        let result = infer_match_criteria(
11913            "Microsoft.PowerToys",
11914            "PowerToys",
11915            Some("powertoys"),
11916            &query,
11917            SearchSemantics::Many,
11918            |_| Ok(Some("powertoys".to_owned())),
11919            |_| Ok(None),
11920        )
11921        .expect("match criteria");
11922
11923        assert_eq!(result, None);
11924    }
11925
11926    #[test]
11927    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
11928        let query = PackageQuery {
11929            query: Some("PowerToys".to_owned()),
11930            ..Default::default()
11931        };
11932        let unknown_version = SearchMatch {
11933            source_name: "msstore".to_owned(),
11934            source_kind: SourceKind::Rest,
11935            id: "XP89DCGQ3K6VLD".to_owned(),
11936            name: "Microsoft PowerToys".to_owned(),
11937            moniker: None,
11938            version: Some("Unknown".to_owned()),
11939            channel: None,
11940            match_criteria: None,
11941        };
11942        let tag_match = SearchMatch {
11943            source_name: "winget".to_owned(),
11944            source_kind: SourceKind::PreIndexed,
11945            id: "Riri.QRCodeforCmdPal".to_owned(),
11946            name: "QR Code for CmdPal".to_owned(),
11947            moniker: None,
11948            version: Some("0.0.1.0".to_owned()),
11949            channel: None,
11950            match_criteria: Some("Tag: powertoys".to_owned()),
11951        };
11952
11953        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
11954    }
11955
11956    #[test]
11957    fn search_source_fetch_results_ignore_small_display_count() {
11958        let query = PackageQuery {
11959            count: Some(5),
11960            ..Default::default()
11961        };
11962
11963        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
11964        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
11965    }
11966
11967    #[test]
11968    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
11969        let query = PackageQuery {
11970            query: Some("PowerToys".to_owned()),
11971            ..Default::default()
11972        };
11973        let prefix_name = SearchMatch {
11974            source_name: "winget".to_owned(),
11975            source_kind: SourceKind::PreIndexed,
11976            id: "advaith.CurrencyConverterPowerToys".to_owned(),
11977            name: "PowerToys-Run-Currency-Converter".to_owned(),
11978            moniker: None,
11979            version: Some("1.5.4".to_owned()),
11980            channel: None,
11981            match_criteria: None,
11982        };
11983        let exact_tag = SearchMatch {
11984            source_name: "winget".to_owned(),
11985            source_kind: SourceKind::PreIndexed,
11986            id: "Riri.QRCodeforCmdPal".to_owned(),
11987            name: "QR Code for CmdPal".to_owned(),
11988            moniker: None,
11989            version: Some("0.0.1.0".to_owned()),
11990            channel: None,
11991            match_criteria: Some("Tag: powertoys".to_owned()),
11992        };
11993
11994        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
11995    }
11996
11997    #[test]
11998    fn list_sort_prefers_main_package_then_sparse_app() {
11999        let main = InstalledPackage {
12000            name: "PowerToys (Preview) x64".to_owned(),
12001            local_id: r"ARP\User\X64\PowerToys".to_owned(),
12002            installed_version: "0.98.1".to_owned(),
12003            publisher: None,
12004            scope: Some("User".to_owned()),
12005            installer_category: Some("exe".to_owned()),
12006            install_location: None,
12007            package_family_names: Vec::new(),
12008            product_codes: Vec::new(),
12009            upgrade_codes: Vec::new(),
12010            correlated: None,
12011            installed_version_canonical: false,
12012            correlated_requires_explicit_upgrade: false,
12013            correlated_lacks_compatible_installer: false,
12014        };
12015        let sparse = InstalledPackage {
12016            name: "PowerToys.SparseApp".to_owned(),
12017            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
12018            installed_version: "0.98.1.0".to_owned(),
12019            publisher: None,
12020            scope: Some("User".to_owned()),
12021            installer_category: Some("msix".to_owned()),
12022            install_location: None,
12023            package_family_names: Vec::new(),
12024            product_codes: Vec::new(),
12025            upgrade_codes: Vec::new(),
12026            correlated: None,
12027            installed_version_canonical: false,
12028            correlated_requires_explicit_upgrade: false,
12029            correlated_lacks_compatible_installer: false,
12030        };
12031        let extension = InstalledPackage {
12032            name: "PowerToys FileLocksmith Context Menu".to_owned(),
12033            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
12034            installed_version: "0.98.1.0".to_owned(),
12035            publisher: None,
12036            scope: Some("User".to_owned()),
12037            installer_category: Some("msix".to_owned()),
12038            install_location: None,
12039            package_family_names: Vec::new(),
12040            product_codes: Vec::new(),
12041            upgrade_codes: Vec::new(),
12042            correlated: None,
12043            installed_version_canonical: false,
12044            correlated_requires_explicit_upgrade: false,
12045            correlated_lacks_compatible_installer: false,
12046        };
12047
12048        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
12049        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
12050    }
12051
12052    #[test]
12053    #[cfg(windows)]
12054    fn arp_subkey_name_extracts_registry_subkey() {
12055        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
12056        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
12057    }
12058
12059    #[test]
12060    #[cfg(windows)]
12061    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
12062        let installed = ListMatch {
12063            name: "ShareX".to_owned(),
12064            id: "ShareX.ShareX".to_owned(),
12065            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
12066            installed_version: "19.0.2".to_owned(),
12067            available_version: None,
12068            source_name: Some("winget".to_owned()),
12069            publisher: Some("ShareX Team".to_owned()),
12070            scope: Some("Machine".to_owned()),
12071            installer_category: Some("exe".to_owned()),
12072            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
12073            package_family_names: Vec::new(),
12074            product_codes: Vec::new(),
12075            upgrade_codes: Vec::new(),
12076        };
12077
12078        assert!(registry_entry_matches_installed_package(
12079            "ShareX", "ShareX", None, &installed
12080        ));
12081        assert!(!registry_entry_matches_installed_package(
12082            "ShareX.ShareX",
12083            "ShareX.ShareX",
12084            None,
12085            &installed
12086        ));
12087    }
12088
12089    #[test]
12090    #[cfg(windows)]
12091    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
12092        assert_eq!(
12093            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
12094            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
12095        );
12096        assert_eq!(
12097            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
12098            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
12099        );
12100        assert_eq!(
12101            r#""C:\Program Files\ShareX\unins000.exe""#,
12102            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
12103        );
12104        assert_eq!(
12105            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12106            build_uninstall_command(
12107                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12108                true,
12109                false,
12110            )
12111        );
12112    }
12113
12114    #[test]
12115    #[cfg(windows)]
12116    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
12117        let installed = ListMatch {
12118            name: "lazygit".to_owned(),
12119            id: "JesseDuffield.lazygit".to_owned(),
12120            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
12121            installed_version: "0.61.1".to_owned(),
12122            available_version: None,
12123            source_name: Some("winget".to_owned()),
12124            publisher: Some("Jesse Duffield".to_owned()),
12125            scope: Some("User".to_owned()),
12126            installer_category: Some("exe".to_owned()),
12127            install_location: Some(
12128                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
12129            ),
12130            package_family_names: Vec::new(),
12131            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
12132            upgrade_codes: Vec::new(),
12133        };
12134        let mut request = UninstallRequest::new(PackageQuery {
12135            id: Some("JesseDuffield.lazygit".to_owned()),
12136            install_scope: Some("user".to_owned()),
12137            ..PackageQuery::default()
12138        });
12139        request.mode = InstallerMode::Silent;
12140
12141        assert_eq!(
12142            Some(vec![
12143                "uninstall".to_owned(),
12144                "--id".to_owned(),
12145                "JesseDuffield.lazygit".to_owned(),
12146                "--exact".to_owned(),
12147                "--disable-interactivity".to_owned(),
12148                "--source".to_owned(),
12149                "winget".to_owned(),
12150                "--scope".to_owned(),
12151                "user".to_owned(),
12152                "--silent".to_owned(),
12153            ]),
12154            build_winget_uninstall_arguments(&installed, &request)
12155        );
12156
12157        assert_eq!(
12158            Some(vec![
12159                "uninstall".to_owned(),
12160                "--id".to_owned(),
12161                "JesseDuffield.lazygit".to_owned(),
12162                "--exact".to_owned(),
12163                "--disable-interactivity".to_owned(),
12164                "--source".to_owned(),
12165                "winget".to_owned(),
12166                "--silent".to_owned(),
12167            ]),
12168            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
12169        );
12170    }
12171
12172    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
12173        PackageVersionDataEntry {
12174            version: version.to_owned(),
12175            manifest_relative_path: format!("manifests/x/{version}"),
12176            manifest_hash: "00".repeat(32),
12177            arp_min_version: arp_min.map(str::to_owned),
12178            arp_max_version: arp_max.map(str::to_owned),
12179        }
12180    }
12181
12182    fn v1_version(version: &str, channel: &str) -> V1VersionRow {
12183        V1VersionRow {
12184            version: version.to_owned(),
12185            channel: channel.to_owned(),
12186            pathpart_id: 0,
12187            manifest_hash: None,
12188        }
12189    }
12190
12191    #[derive(Clone)]
12192    struct TestHttpResponse {
12193        status: u16,
12194        body: Vec<u8>,
12195        source_version: Option<String>,
12196    }
12197
12198    struct TestHttpServer {
12199        addr: SocketAddr,
12200        routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12201        counts: Arc<Mutex<HashMap<String, usize>>>,
12202        running: Arc<AtomicBool>,
12203        handle: Option<JoinHandle<()>>,
12204    }
12205
12206    impl TestHttpServer {
12207        fn start() -> Self {
12208            let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
12209            let addr = listener.local_addr().expect("test HTTP server address");
12210            let routes = Arc::new(Mutex::new(HashMap::new()));
12211            let counts = Arc::new(Mutex::new(HashMap::new()));
12212            let running = Arc::new(AtomicBool::new(true));
12213            let thread_routes = Arc::clone(&routes);
12214            let thread_counts = Arc::clone(&counts);
12215            let thread_running = Arc::clone(&running);
12216            let handle = thread::spawn(move || {
12217                while thread_running.load(AtomicOrdering::SeqCst) {
12218                    let Ok((stream, _)) = listener.accept() else {
12219                        continue;
12220                    };
12221                    handle_test_http_request(stream, &thread_routes, &thread_counts);
12222                }
12223            });
12224
12225            Self {
12226                addr,
12227                routes,
12228                counts,
12229                running,
12230                handle: Some(handle),
12231            }
12232        }
12233
12234        fn url(&self) -> String {
12235            format!("http://{}", self.addr)
12236        }
12237
12238        fn set_bytes(&self, path: &str, body: Vec<u8>) {
12239            self.set_response(
12240                path,
12241                TestHttpResponse {
12242                    status: 200,
12243                    body,
12244                    source_version: None,
12245                },
12246            );
12247        }
12248
12249        fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
12250            self.set_response(
12251                "/source.msix",
12252                TestHttpResponse {
12253                    status: 200,
12254                    body,
12255                    source_version: Some(source_version.to_owned()),
12256                },
12257            );
12258        }
12259
12260        fn set_status(&self, path: &str, status: u16) {
12261            self.set_response(
12262                path,
12263                TestHttpResponse {
12264                    status,
12265                    body: Vec::new(),
12266                    source_version: None,
12267                },
12268            );
12269        }
12270
12271        fn set_response(&self, path: &str, response: TestHttpResponse) {
12272            self.routes
12273                .lock()
12274                .expect("test HTTP routes")
12275                .insert(path.to_owned(), response);
12276        }
12277
12278        fn request_count(&self, path: &str) -> usize {
12279            self.counts
12280                .lock()
12281                .expect("test HTTP counts")
12282                .get(path)
12283                .copied()
12284                .unwrap_or_default()
12285        }
12286    }
12287
12288    impl Drop for TestHttpServer {
12289        fn drop(&mut self) {
12290            self.running.store(false, AtomicOrdering::SeqCst);
12291            let _ = TcpStream::connect(self.addr);
12292            if let Some(handle) = self.handle.take() {
12293                let _ = handle.join();
12294            }
12295        }
12296    }
12297
12298    fn handle_test_http_request(
12299        mut stream: TcpStream,
12300        routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12301        counts: &Arc<Mutex<HashMap<String, usize>>>,
12302    ) {
12303        let mut buffer = [0u8; 2048];
12304        let read = stream.read(&mut buffer).unwrap_or(0);
12305        let request = String::from_utf8_lossy(&buffer[..read]);
12306        let path = request
12307            .lines()
12308            .next()
12309            .and_then(|line| line.split_whitespace().nth(1))
12310            .unwrap_or("/");
12311        *counts
12312            .lock()
12313            .expect("test HTTP counts")
12314            .entry(path.to_owned())
12315            .or_default() += 1;
12316        let response = routes.lock().expect("test HTTP routes").get(path).cloned();
12317        let response = response.unwrap_or(TestHttpResponse {
12318            status: 404,
12319            body: Vec::new(),
12320            source_version: None,
12321        });
12322        let reason = if response.status == 200 { "OK" } else { "Not Found" };
12323        let mut headers = format!(
12324            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
12325            response.status,
12326            reason,
12327            response.body.len()
12328        );
12329        if let Some(source_version) = response.source_version {
12330            headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
12331        }
12332        headers.push_str("\r\n");
12333        stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
12334        stream.write_all(&response.body).expect("write test HTTP body");
12335    }
12336
12337    fn test_source_record(source_url: String) -> SourceRecord {
12338        SourceRecord {
12339            name: "test-winget".to_owned(),
12340            kind: SourceKind::PreIndexed,
12341            arg: source_url,
12342            identifier: "Test.Source".to_owned(),
12343            trust_level: "Trusted".to_owned(),
12344            explicit: false,
12345            priority: 0,
12346            last_update: Some(Utc::now()),
12347            source_version: None,
12348        }
12349    }
12350
12351    fn open_test_preindexed_repository(
12352        app_root: &Path,
12353        source: &SourceRecord,
12354        interval: Option<Duration>,
12355    ) -> Repository {
12356        fs::create_dir_all(app_root).expect("create app root");
12357        save_store(
12358            app_root,
12359            &SourceStore {
12360                sources: vec![source.clone()],
12361            },
12362        )
12363        .expect("save source store");
12364        Repository::open_with_options(
12365            RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
12366        )
12367        .expect("open repository")
12368    }
12369
12370    fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
12371        let index_path = preindexed_index_path(app_root, source);
12372        if let Some(parent) = index_path.parent() {
12373            fs::create_dir_all(parent).expect("create source state directory");
12374        }
12375        fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
12376    }
12377
12378    #[test]
12379    fn replace_file_failure_keeps_existing_target() {
12380        let root = temp_app_root("replace_file_failure");
12381        fs::create_dir_all(&root).expect("create replace root");
12382        let target = root.join("index.db");
12383        let missing_source = root.join("missing.tmp");
12384        fs::write(&target, b"old-index").expect("write existing target");
12385
12386        let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
12387
12388        assert!(format!("{error:#}").contains("failed to replace"));
12389        assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
12390        let _ = fs::remove_dir_all(root);
12391    }
12392
12393    #[cfg(windows)]
12394    #[test]
12395    fn replace_file_succeeds_while_target_is_open() {
12396        use std::os::windows::fs::OpenOptionsExt;
12397        // FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE — the sharing
12398        // mode SQLite/Turso uses. An atomic MOVEFILE_REPLACE_EXISTING is denied
12399        // while this handle is open, so the stage-aside fallback must take over.
12400        const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
12401
12402        let root = temp_app_root("replace_file_open_target");
12403        fs::create_dir_all(&root).expect("create replace root");
12404        let target = root.join("index.db");
12405        let source = root.join("index.new.tmp");
12406        fs::write(&target, b"old-index").expect("write existing target");
12407        fs::write(&source, b"new-index").expect("write replacement");
12408
12409        let held = fs::OpenOptions::new()
12410            .read(true)
12411            .share_mode(SHARE_ALL)
12412            .open(&target)
12413            .expect("open target with share-delete");
12414
12415        replace_file(&source, &target).expect("stage-aside replace should succeed");
12416
12417        assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
12418        drop(held);
12419        let _ = fs::remove_dir_all(root);
12420    }
12421
12422    fn make_source_package(versions: &[&str]) -> Vec<u8> {
12423        let cursor = Cursor::new(Vec::new());
12424        let mut archive = zip::ZipWriter::new(cursor);
12425        let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
12426        archive
12427            .start_file("Public/index.db", options)
12428            .expect("start index entry");
12429        archive
12430            .write_all(&make_v1_index_bytes(versions))
12431            .expect("write index entry");
12432        archive.finish().expect("finish source package").into_inner()
12433    }
12434
12435    fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
12436        let root = temp_app_root("preindexed_index_bytes");
12437        fs::create_dir_all(&root).expect("create index temp root");
12438        let db_path = root.join("index.db");
12439        let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
12440        let mut sql = String::from(
12441            "CREATE TABLE ids (id TEXT);
12442             CREATE TABLE names (name TEXT);
12443             CREATE TABLE monikers (moniker TEXT);
12444             CREATE TABLE versions (version TEXT);
12445             CREATE TABLE channels (channel TEXT);
12446             CREATE TABLE pathparts (parent INT64, pathpart TEXT);
12447             CREATE TABLE manifest (
12448                 id INT64,
12449                 name INT64,
12450                 moniker INT64,
12451                 version INT64,
12452                 channel INT64,
12453                 pathpart INT64
12454             );
12455             INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
12456             INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
12457             INSERT INTO channels(rowid, channel) VALUES (1, '');
12458             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
12459             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
12460        );
12461        for (index, version) in versions.iter().enumerate() {
12462            let rowid = index + 1;
12463            sql.push_str(&format!(
12464                "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
12465                 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
12466            ));
12467        }
12468        execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
12469        let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
12470            .expect("checkpoint preindexed V1 database");
12471        connection.cacheflush().expect("flush preindexed V1 database");
12472        drop(connection);
12473        let bytes = fs::read(&db_path).expect("read index bytes");
12474        let _ = fs::remove_dir_all(root);
12475        bytes
12476    }
12477
12478    fn test_manifest_yaml(version: &str) -> Vec<u8> {
12479        format!(
12480            "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
12481        )
12482        .into_bytes()
12483    }
12484
12485    fn show_test_package(version: Option<&str>) -> PackageQuery {
12486        PackageQuery {
12487            id: Some("Test.Package".to_owned()),
12488            source: Some("test-winget".to_owned()),
12489            exact: true,
12490            version: version.map(str::to_owned),
12491            ..PackageQuery::default()
12492        }
12493    }
12494
12495    #[test]
12496    fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
12497        let app_root = temp_app_root("preindexed_explicit_refresh");
12498        let server = TestHttpServer::start();
12499        server.set_status("/source2.msix", 404);
12500        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
12501        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12502        let source = test_source_record(server.url());
12503        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12504        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12505
12506        let result = repository
12507            .show(&show_test_package(Some("2.0.0")))
12508            .expect("show after refresh");
12509
12510        assert_eq!(result.manifest.version, "2.0.0");
12511        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
12512        let _ = fs::remove_dir_all(app_root);
12513    }
12514
12515    #[test]
12516    fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
12517        let app_root = temp_app_root("preindexed_explicit_still_missing");
12518        let server = TestHttpServer::start();
12519        server.set_status("/source2.msix", 404);
12520        server.set_source_package(make_source_package(&["1.0.0"]), "same");
12521        let source = test_source_record(server.url());
12522        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12523        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12524
12525        let error = repository
12526            .show(&show_test_package(Some("2.0.0")))
12527            .expect_err("missing explicit version must fail");
12528
12529        assert!(format!("{error:#}").contains("2.0.0"));
12530        let _ = fs::remove_dir_all(app_root);
12531    }
12532
12533    #[test]
12534    fn preindexed_stale_index_refreshes_for_latest_requests() {
12535        let app_root = temp_app_root("preindexed_ttl_refresh");
12536        let server = TestHttpServer::start();
12537        server.set_status("/source2.msix", 404);
12538        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12539        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12540        let mut source = test_source_record(server.url());
12541        source.last_update = Some(Utc::now() - Duration::days(2));
12542        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12543        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12544
12545        let result = repository
12546            .show(&show_test_package(None))
12547            .expect("show latest after TTL refresh");
12548
12549        assert_eq!(result.manifest.version, "2.0.0");
12550        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
12551        let _ = fs::remove_dir_all(app_root);
12552    }
12553
12554    #[test]
12555    fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
12556        let app_root = temp_app_root("preindexed_ttl_fallback");
12557        let server = TestHttpServer::start();
12558        server.set_status("/source2.msix", 404);
12559        server.set_status("/source.msix", 404);
12560        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
12561        let mut source = test_source_record(server.url());
12562        source.last_update = Some(Utc::now() - Duration::days(2));
12563        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12564        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12565
12566        let result = repository
12567            .show(&show_test_package(None))
12568            .expect("show latest from stale cache after refresh failure");
12569
12570        assert_eq!(result.manifest.version, "1.0.0");
12571        let _ = fs::remove_dir_all(app_root);
12572    }
12573
12574    #[test]
12575    fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
12576        let app_root = temp_app_root("preindexed_no_double_refresh");
12577        let server = TestHttpServer::start();
12578        server.set_status("/source2.msix", 404);
12579        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12580        let mut source = test_source_record(server.url());
12581        source.last_update = Some(Utc::now() - Duration::days(2));
12582        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12583        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12584
12585        let error = repository
12586            .show(&show_test_package(Some("3.0.0")))
12587            .expect_err("still-missing explicit version must fail after one stale refresh");
12588
12589        assert!(format!("{error:#}").contains("3.0.0"));
12590        assert_eq!(server.request_count("/source.msix"), 1);
12591        let _ = fs::remove_dir_all(app_root);
12592    }
12593
12594    #[test]
12595    fn select_v2_version_without_explicit_filters_returns_latest() {
12596        let versions = vec![
12597            version_entry("1.0.0", None, None),
12598            version_entry("1.2.0", None, None),
12599            version_entry("1.1.0", None, None),
12600        ];
12601
12602        let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
12603
12604        assert_eq!(selected.version, "1.2.0");
12605    }
12606
12607    #[test]
12608    fn select_v2_version_with_missing_requested_version_throws() {
12609        let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
12610
12611        let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
12612            .expect_err("missing requested version");
12613
12614        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12615        assert!(error.to_string().contains("9.9.9"));
12616    }
12617
12618    #[test]
12619    fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
12620        let versions = vec![version_entry("1.2.0", None, None)];
12621
12622        let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12623            .expect_err("unsupported channel request");
12624
12625        assert!(error.to_string().contains("beta"));
12626        assert!(error.to_string().contains("channel metadata"));
12627    }
12628
12629    #[test]
12630    fn select_v1_version_channel_only_returns_latest_matching_channel() {
12631        let versions = vec![
12632            v1_version("1.2.0", "stable"),
12633            v1_version("1.3.0", "stable"),
12634            v1_version("2.0.0", "preview"),
12635        ];
12636
12637        let selected =
12638            select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
12639
12640        assert_eq!(selected.version, "1.3.0");
12641    }
12642
12643    #[test]
12644    fn select_rest_version_channel_mismatch_throws() {
12645        let versions = vec![
12646            VersionKey {
12647                version: "1.2.0".to_owned(),
12648                channel: "stable".to_owned(),
12649            },
12650            VersionKey {
12651                version: "1.1.0".to_owned(),
12652                channel: "beta".to_owned(),
12653            },
12654        ];
12655
12656        let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12657            .expect_err("missing version and channel pair");
12658
12659        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12660    }
12661
12662    #[test]
12663    fn map_arp_version_returns_catalog_version_inside_range() {
12664        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
12665        // `10.1.826.23019`. compare_version on that against the catalog Version
12666        // `10.0.108` claims installed > available, so without this mapping the
12667        // upgrade was silently dropped.
12668        let entries = vec![
12669            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
12670            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
12671            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
12672        ];
12673
12674        assert_eq!(
12675            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
12676            Some("10.0.108")
12677        );
12678    }
12679
12680    #[test]
12681    fn map_arp_version_returns_none_when_no_range_matches() {
12682        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
12683        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
12684        // original installed version (and accept that no upgrade can be
12685        // computed) — same as winget's behavior.
12686        let entries = vec![
12687            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
12688            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
12689        ];
12690
12691        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
12692    }
12693
12694    #[test]
12695    fn map_arp_version_skips_entries_missing_arp_bounds() {
12696        // Older catalog packages predate AppsAndFeaturesEntries and have no
12697        // aMiV/aMaV. Those entries must be ignored — not silently treated as
12698        // an unbounded range.
12699        let entries = vec![
12700            version_entry("9.0.300", None, None),
12701            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
12702        ];
12703
12704        assert_eq!(
12705            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
12706            Some("9.0.117")
12707        );
12708        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
12709    }
12710
12711    #[test]
12712    fn map_arp_version_handles_inclusive_range_endpoints() {
12713        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
12714        // must still map.
12715        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
12716
12717        assert_eq!(
12718            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
12719            Some("3.14.5")
12720        );
12721        assert_eq!(
12722            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
12723            Some("3.14.5")
12724        );
12725        assert_eq!(
12726            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
12727            Some("3.14.5")
12728        );
12729    }
12730
12731    #[test]
12732    fn map_arp_version_ignores_unknown_marker() {
12733        // collect_installed_packages writes the literal "Unknown" when ARP has
12734        // no DisplayVersion. Trying to map that would falsely match the first
12735        // entry whose aMiV..aMaV string-compares to include "Unknown".
12736        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
12737
12738        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
12739        assert!(map_arp_version_to_catalog(&entries, "").is_none());
12740    }
12741
12742    #[test]
12743    fn latest_arp_anchored_skips_internal_versions() {
12744        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
12745        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
12746        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
12747        // it against an installed canonical of `1.8.6` would falsely report
12748        // an upgrade. Returns the highest anchored Version instead.
12749        let entries = vec![
12750            version_entry("8000.836.2153.0", None, None),
12751            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
12752            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
12753            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
12754        ];
12755
12756        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
12757    }
12758
12759    #[test]
12760    fn latest_arp_anchored_returns_none_without_bounds() {
12761        // App Installer's versionData has no aMiV/aMaV — every version is a
12762        // user-facing MSIX Version. Caller must fall back to
12763        // `packages.latest_version` in this case.
12764        let entries = vec![
12765            version_entry("1.28.240.0", None, None),
12766            version_entry("1.27.470.0", None, None),
12767        ];
12768
12769        assert!(latest_arp_anchored_version(&entries).is_none());
12770    }
12771
12772    #[test]
12773    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
12774        let package = InstalledPackage {
12775            name: "AzCopy v10".to_owned(),
12776            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
12777            installed_version: "10.32.2".to_owned(),
12778            publisher: Some("Microsoft".to_owned()),
12779            scope: Some("Machine".to_owned()),
12780            installer_category: Some("exe".to_owned()),
12781            install_location: None,
12782            package_family_names: Vec::new(),
12783            product_codes: Vec::new(),
12784            upgrade_codes: Vec::new(),
12785            correlated: Some(SearchMatch {
12786                source_name: "winget".to_owned(),
12787                source_kind: SourceKind::PreIndexed,
12788                id: "Microsoft.Azure.AZCopy.10".to_owned(),
12789                name: "AzCopy v10".to_owned(),
12790                moniker: None,
12791                version: Some("10.32.3".to_owned()),
12792                channel: None,
12793                match_criteria: None,
12794            }),
12795            installed_version_canonical: false,
12796            correlated_requires_explicit_upgrade: false,
12797            correlated_lacks_compatible_installer: false,
12798        };
12799
12800        let item = list_match_from_installed(package);
12801
12802        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
12803        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
12804        assert_eq!(item.source_name.as_deref(), Some("winget"));
12805        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
12806    }
12807
12808    #[test]
12809    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
12810        let package = InstalledPackage {
12811            name: "Atlassian CLI".to_owned(),
12812            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
12813            installed_version: "1.3.18-stable".to_owned(),
12814            publisher: Some("Atlassian".to_owned()),
12815            scope: Some("User".to_owned()),
12816            installer_category: Some("exe".to_owned()),
12817            install_location: None,
12818            package_family_names: Vec::new(),
12819            product_codes: Vec::new(),
12820            upgrade_codes: Vec::new(),
12821            correlated: Some(SearchMatch {
12822                source_name: "winget".to_owned(),
12823                source_kind: SourceKind::PreIndexed,
12824                id: "Atlassian.AtlassianCLI".to_owned(),
12825                name: "Atlassian CLI".to_owned(),
12826                moniker: None,
12827                version: Some("1.3.18-stable".to_owned()),
12828                channel: None,
12829                match_criteria: Some("ProductCode".to_owned()),
12830            }),
12831            installed_version_canonical: false,
12832            correlated_requires_explicit_upgrade: false,
12833            correlated_lacks_compatible_installer: false,
12834        };
12835
12836        let item = list_match_from_installed(package);
12837
12838        assert_eq!(item.id, "Atlassian.AtlassianCLI");
12839        assert_eq!(
12840            item.local_id,
12841            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
12842        );
12843        assert_ne!(item.id, item.local_id);
12844        assert_eq!(item.source_name.as_deref(), Some("winget"));
12845    }
12846
12847    #[test]
12848    fn list_response_serialization_keeps_pascal_case_fields() {
12849        let response = ListResponse {
12850            matches: vec![ListMatch {
12851                name: "PowerToys".to_owned(),
12852                id: "Microsoft.PowerToys".to_owned(),
12853                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
12854                installed_version: "0.98.1".to_owned(),
12855                available_version: Some("0.99.0".to_owned()),
12856                source_name: Some("winget".to_owned()),
12857                publisher: Some("Microsoft".to_owned()),
12858                scope: Some("Machine".to_owned()),
12859                installer_category: Some("msi".to_owned()),
12860                install_location: None,
12861                package_family_names: Vec::new(),
12862                product_codes: Vec::new(),
12863                upgrade_codes: Vec::new(),
12864            }],
12865            warnings: Vec::new(),
12866            truncated: false,
12867        };
12868
12869        let value = serde_json::to_value(&response).expect("serialize list response");
12870        let match_value = &value["Matches"][0];
12871
12872        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
12873        assert_eq!(match_value["InstalledVersion"], "0.98.1");
12874        assert_eq!(match_value["AvailableVersion"], "0.99.0");
12875        assert_eq!(match_value["SourceName"], "winget");
12876        assert!(match_value.get("local_id").is_none());
12877        assert!(match_value.get("installed_version").is_none());
12878        assert!(match_value.get("available_version").is_none());
12879        assert!(match_value.get("source_name").is_none());
12880    }
12881
12882    #[test]
12883    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
12884        let response = SearchResponse {
12885            matches: vec![SearchMatch {
12886                source_name: "msstore".to_owned(),
12887                source_kind: SourceKind::Rest,
12888                id: "9WZDNCRFJBMP".to_owned(),
12889                name: "Microsoft To Do".to_owned(),
12890                moniker: None,
12891                version: Some("2.123.456.0".to_owned()),
12892                channel: None,
12893                match_criteria: None,
12894            }],
12895            warnings: Vec::new(),
12896            truncated: false,
12897        };
12898
12899        let value = serde_json::to_value(&response).expect("serialize search response");
12900        let match_value = &value["Matches"][0];
12901
12902        assert_eq!(match_value["Name"], "Microsoft To Do");
12903        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
12904        assert_eq!(match_value["Version"], "2.123.456.0");
12905        assert_eq!(match_value["SourceName"], "msstore");
12906        assert!(match_value.get("source_name").is_none());
12907    }
12908
12909    #[test]
12910    fn versions_result_serialization_keeps_pascal_case_fields() {
12911        let result = VersionsResult {
12912            package: SearchMatch {
12913                source_name: "winget".to_owned(),
12914                source_kind: SourceKind::PreIndexed,
12915                id: "Microsoft.PowerToys".to_owned(),
12916                name: "PowerToys".to_owned(),
12917                moniker: None,
12918                version: Some("0.99.0".to_owned()),
12919                channel: None,
12920                match_criteria: None,
12921            },
12922            versions: vec![
12923                VersionKey {
12924                    version: "0.99.0".to_owned(),
12925                    channel: "stable".to_owned(),
12926                },
12927                VersionKey {
12928                    version: "0.100.0-preview".to_owned(),
12929                    channel: "preview".to_owned(),
12930                },
12931            ],
12932            warnings: Vec::new(),
12933        };
12934
12935        let value = serde_json::to_value(&result).expect("serialize versions result");
12936
12937        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
12938        assert_eq!(value["Versions"][0]["Channel"], "stable");
12939        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
12940        assert_eq!(value["Versions"][1]["Channel"], "preview");
12941        assert!(value.get("versions").is_none());
12942    }
12943
12944    #[test]
12945    fn version_data_parses_arp_bounds_from_winget_payload() {
12946        // Sanity check that aMiV/aMaV deserialize from a real-world
12947        // versionData.mszyml payload shape. Without `default`, packages
12948        // predating AppsAndFeaturesEntries would fail to parse.
12949        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";
12950
12951        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
12952        assert_eq!(document.versions.len(), 2);
12953        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
12954        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
12955        assert!(document.versions[1].arp_min_version.is_none());
12956        assert!(document.versions[1].arp_max_version.is_none());
12957    }
12958
12959    #[test]
12960    fn msix_resource_string_name_resolves_to_catalog_name() {
12961        // App Installer's MSIX manifest stores DisplayName as
12962        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
12963        // know the catalog calls it `App Installer` — show that instead of
12964        // the unresolved placeholder, matching winget's output.
12965        let mut package = InstalledPackage {
12966            name: "ms-resource:appDisplayName".to_owned(),
12967            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
12968            installed_version: "1.28.239.0".to_owned(),
12969            publisher: None,
12970            scope: Some("User".to_owned()),
12971            installer_category: Some("msix".to_owned()),
12972            install_location: None,
12973            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
12974            product_codes: Vec::new(),
12975            upgrade_codes: Vec::new(),
12976            correlated: Some(SearchMatch {
12977                source_name: "winget".to_owned(),
12978                source_kind: SourceKind::PreIndexed,
12979                id: "Microsoft.AppInstaller".to_owned(),
12980                name: "App Installer".to_owned(),
12981                moniker: None,
12982                version: Some("1.28.240.0".to_owned()),
12983                channel: None,
12984                match_criteria: Some("PackageFamilyName".to_owned()),
12985            }),
12986            installed_version_canonical: false,
12987            correlated_requires_explicit_upgrade: false,
12988            correlated_lacks_compatible_installer: false,
12989        };
12990        apply_msix_resource_string_name_fix(&mut package);
12991        assert_eq!(package.name, "App Installer");
12992    }
12993
12994    #[test]
12995    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
12996        // ARP rows can also carry unusual display names; the fix is gated
12997        // on the local_id prefix so a `ms-resource:` literal that somehow
12998        // appears in an ARP DisplayName (extremely unlikely, but possible)
12999        // doesn't get silently rewritten.
13000        let mut package = InstalledPackage {
13001            name: "ms-resource:appDisplayName".to_owned(),
13002            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
13003            installed_version: "1.0".to_owned(),
13004            publisher: None,
13005            scope: Some("Machine".to_owned()),
13006            installer_category: Some("msi".to_owned()),
13007            install_location: None,
13008            package_family_names: Vec::new(),
13009            product_codes: Vec::new(),
13010            upgrade_codes: Vec::new(),
13011            correlated: Some(SearchMatch {
13012                source_name: "winget".to_owned(),
13013                source_kind: SourceKind::PreIndexed,
13014                id: "Some.Package".to_owned(),
13015                name: "Should Not Apply".to_owned(),
13016                moniker: None,
13017                version: Some("1.0".to_owned()),
13018                channel: None,
13019                match_criteria: None,
13020            }),
13021            installed_version_canonical: false,
13022            correlated_requires_explicit_upgrade: false,
13023            correlated_lacks_compatible_installer: false,
13024        };
13025        apply_msix_resource_string_name_fix(&mut package);
13026        assert_eq!(package.name, "ms-resource:appDisplayName");
13027    }
13028
13029    #[test]
13030    fn msix_resource_string_fix_skips_resolved_names() {
13031        // The vast majority of MSIX entries have already-resolved names
13032        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
13033        // those — `meta.name` and the installed name may legitimately
13034        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
13035        let mut package = InstalledPackage {
13036            name: "Microsoft Teams".to_owned(),
13037            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13038            installed_version: "25290.205.4069.4894".to_owned(),
13039            publisher: None,
13040            scope: Some("User".to_owned()),
13041            installer_category: Some("msix".to_owned()),
13042            install_location: None,
13043            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13044            product_codes: Vec::new(),
13045            upgrade_codes: Vec::new(),
13046            correlated: Some(SearchMatch {
13047                source_name: "winget".to_owned(),
13048                source_kind: SourceKind::PreIndexed,
13049                id: "Microsoft.Teams".to_owned(),
13050                name: "Microsoft Teams Catalog Name".to_owned(),
13051                moniker: None,
13052                version: Some("26106.1906.4665.7308".to_owned()),
13053                channel: None,
13054                match_criteria: Some("PackageFamilyName".to_owned()),
13055            }),
13056            installed_version_canonical: false,
13057            correlated_requires_explicit_upgrade: false,
13058            correlated_lacks_compatible_installer: false,
13059        };
13060        apply_msix_resource_string_name_fix(&mut package);
13061        assert_eq!(package.name, "Microsoft Teams");
13062    }
13063
13064    #[cfg(windows)]
13065    #[test]
13066    fn unflip_packed_guid_reverses_msi_installer_packing() {
13067        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
13068        // chunks (8/4/4/2*8). This is the encoding used inside
13069        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
13070        // and their value names. Verified against the user's installed
13071        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
13072        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
13073        assert_eq!(
13074            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
13075            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
13076        );
13077        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
13078        // form `A3A70C74FE2431248AD5F8A59570C782`.
13079        assert_eq!(
13080            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
13081            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
13082        );
13083        // Reject non-32-char or non-hex inputs.
13084        assert!(unflip_packed_guid("nothex").is_none());
13085        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
13086        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
13087    }
13088
13089    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
13090        InstalledPackage {
13091            name: format!("{id} install"),
13092            local_id: format!(r"ARP\Machine\X64\{id}"),
13093            installed_version: installed_version.to_owned(),
13094            publisher: None,
13095            scope: Some("Machine".to_owned()),
13096            installer_category: Some("msi".to_owned()),
13097            install_location: None,
13098            package_family_names: Vec::new(),
13099            product_codes: Vec::new(),
13100            upgrade_codes: Vec::new(),
13101            correlated: Some(SearchMatch {
13102                source_name: "winget".to_owned(),
13103                source_kind: SourceKind::PreIndexed,
13104                id: id.to_owned(),
13105                name: id.to_owned(),
13106                moniker: None,
13107                version: Some("99.0.0".to_owned()),
13108                channel: None,
13109                match_criteria: None,
13110            }),
13111            installed_version_canonical: canonical,
13112            correlated_requires_explicit_upgrade: false,
13113            correlated_lacks_compatible_installer: false,
13114        }
13115    }
13116
13117    #[test]
13118    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
13119        // Two installed entries correlate to the same catalog id. One had its
13120        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
13121        // the other did not (e.g. VS-installed .NET SDK whose ARP
13122        // `40.10.18029` doesn't fit any bucket). Without the canonical
13123        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
13124        // wrong row wins and the upgrade silently disappears.
13125        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
13126        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
13127
13128        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
13129        assert_eq!(result.len(), 1);
13130        assert_eq!(result[0].installed_version, "10.0.108");
13131        assert!(result[0].installed_version_canonical);
13132    }
13133
13134    #[test]
13135    fn dedupe_keeps_highest_among_canonical_rows() {
13136        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
13137        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
13138
13139        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
13140        assert_eq!(result.len(), 1);
13141        assert_eq!(result[0].installed_version, "1.7.9");
13142    }
13143
13144    #[test]
13145    fn dedupe_leaves_uncorrelated_rows_alone() {
13146        // Uncorrelated entries don't reach upgrade output anyway (the
13147        // upgrade filter requires correlation), but the dedupe pass must not
13148        // drop them — `pinget list` could share this code path in the future.
13149        let uncorrelated = InstalledPackage {
13150            name: "Foo".to_owned(),
13151            local_id: r"ARP\Machine\X64\Foo".to_owned(),
13152            installed_version: "1.0".to_owned(),
13153            publisher: None,
13154            scope: Some("Machine".to_owned()),
13155            installer_category: Some("exe".to_owned()),
13156            install_location: None,
13157            package_family_names: Vec::new(),
13158            product_codes: Vec::new(),
13159            upgrade_codes: Vec::new(),
13160            correlated: None,
13161            installed_version_canonical: false,
13162            correlated_requires_explicit_upgrade: false,
13163            correlated_lacks_compatible_installer: false,
13164        };
13165        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
13166        assert_eq!(result.len(), 1);
13167    }
13168}