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::{self, OpenOptions};
7use std::io::{Cursor, Read, Write};
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};
14use std::time::{Duration as StdDuration, Instant};
15
16use anyhow::{Context, Result, anyhow, bail};
17use chrono::{DateTime, Duration, Utc};
18use reqwest::StatusCode;
19use reqwest::blocking::{Client, Response};
20use reqwest::header::{
21    ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE, IF_NONE_MATCH,
22    IF_RANGE, LAST_MODIFIED, RANGE,
23};
24use serde::{Deserialize, Serialize};
25use serde_json::{Map as JsonMap, Value as JsonValue};
26use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
27use sha2::{Digest, Sha256};
28use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
29#[cfg(windows)]
30use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
31#[cfg(windows)]
32use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
33#[cfg(windows)]
34use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
35#[cfg(windows)]
36use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
37#[cfg(windows)]
38use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
39#[cfg(windows)]
40use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
41#[cfg(windows)]
42use winreg::{RegKey, enums::*};
43use zip::ZipArchive;
44
45const DEFAULT_MARKET: &str = "US";
46const DEFAULT_MAX_RESULTS: usize = 50;
47const LIST_LOOKUP_MAX_RESULTS: usize = 500;
48const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
49const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
50const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15;
51const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
52const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json";
53#[cfg(windows)]
54const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
55#[cfg(windows)]
56const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
57const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
58    "Installed package discovery is not supported on this platform; returning no installed packages.";
59const INSTALL_UNSUPPORTED_WARNING: &str =
60    "Installing packages is not supported on this platform; no changes were made.";
61const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
62const REPAIR_REINSTALL_WARNING: &str =
63    "Pinget repair currently re-runs the package install flow for the selected package.";
64const UNINSTALL_UNSUPPORTED_WARNING: &str =
65    "Uninstalling packages is not supported on this platform; no changes were made.";
66#[cfg(windows)]
67const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
68const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
69    "LocalManifestFiles",
70    "BypassCertificatePinningForMicrosoftStore",
71    "InstallerHashOverride",
72    "LocalArchiveMalwareScanOverride",
73    "ProxyCommandLineOptions",
74];
75const REST_SUPPORTED_CONTRACTS: &[&str] = &[
76    "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",
77];
78
79#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
80pub enum SourceKind {
81    #[serde(rename = "PreIndexed", alias = "preIndexed")]
82    PreIndexed,
83    #[serde(rename = "Rest", alias = "rest")]
84    Rest,
85}
86
87impl Display for SourceKind {
88    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
89        match self {
90            SourceKind::PreIndexed => f.write_str("preindexed"),
91            SourceKind::Rest => f.write_str("rest"),
92        }
93    }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SourceRecord {
98    pub name: String,
99    pub kind: SourceKind,
100    pub arg: String,
101    pub identifier: String,
102    #[serde(default = "default_source_trust_level")]
103    pub trust_level: String,
104    #[serde(default)]
105    pub explicit: bool,
106    #[serde(default)]
107    pub priority: i32,
108    #[serde(default)]
109    pub last_update: Option<DateTime<Utc>>,
110    #[serde(default)]
111    pub source_version: Option<String>,
112    #[serde(default)]
113    pub etag: Option<String>,
114    #[serde(default)]
115    pub last_modified: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119struct SourceStore {
120    sources: Vec<SourceRecord>,
121}
122
123impl Default for SourceStore {
124    fn default() -> Self {
125        Self {
126            sources: vec![
127                SourceRecord {
128                    name: "winget".to_owned(),
129                    kind: SourceKind::PreIndexed,
130                    arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
131                    identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
132                    trust_level: "Trusted".to_owned(),
133                    explicit: false,
134                    priority: 0,
135                    last_update: None,
136                    source_version: None,
137                    etag: None,
138                    last_modified: None,
139                },
140                SourceRecord {
141                    name: "msstore".to_owned(),
142                    kind: SourceKind::Rest,
143                    arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
144                    identifier: "StoreEdgeFD".to_owned(),
145                    trust_level: "Trusted".to_owned(),
146                    explicit: false,
147                    priority: 0,
148                    last_update: None,
149                    source_version: None,
150                    etag: None,
151                    last_modified: None,
152                },
153            ],
154        }
155    }
156}
157
158/// Options for embedding the WinGet core library in another application.
159#[derive(Debug, Clone)]
160pub struct RepositoryOptions {
161    pub app_root: PathBuf,
162    pub user_agent: String,
163    pub source_mode: SourceMode,
164    pub pre_indexed_source_auto_update_interval: Option<Duration>,
165    pub install_progress: Option<fn(&InstallProgress)>,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum SourceMode {
170    Auto,
171    Private,
172    SystemWingetMirror,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum EffectiveSourceMode {
177    Private,
178    SystemWingetDirect,
179    SystemWingetMirror,
180}
181
182impl RepositoryOptions {
183    /// Creates library options that keep all persistent state under the supplied root.
184    pub fn new(app_root: impl Into<PathBuf>) -> Self {
185        Self {
186            app_root: app_root.into(),
187            user_agent: DEFAULT_USER_AGENT.to_owned(),
188            source_mode: SourceMode::Private,
189            pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
190            install_progress: None,
191        }
192    }
193
194    /// Uses the default per-user app-data root that the CLI also uses.
195    pub fn for_current_user() -> Result<Self> {
196        let source_mode = if std::env::var_os("PINGET_APPROOT").is_some() {
197            SourceMode::Private
198        } else {
199            SourceMode::Auto
200        };
201        Ok(Self::new(default_app_root()?).with_source_mode(source_mode))
202    }
203
204    /// Overrides the HTTP user-agent sent to source endpoints.
205    #[must_use]
206    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
207        self.user_agent = user_agent.into();
208        self
209    }
210
211    #[must_use]
212    pub fn with_source_mode(mut self, source_mode: SourceMode) -> Self {
213        self.source_mode = source_mode;
214        self
215    }
216
217    /// Overrides automatic freshness checks for existing pre-indexed source indexes.
218    #[must_use]
219    pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
220        self.pre_indexed_source_auto_update_interval = interval;
221        self
222    }
223
224    /// Receives coarse install/update phase notifications. Intended for CLIs
225    /// and host applications that need to show progress while downloads or
226    /// silent installers are running.
227    #[must_use]
228    pub fn with_install_progress(mut self, progress: fn(&InstallProgress)) -> Self {
229        self.install_progress = Some(progress);
230        self
231    }
232}
233
234#[derive(Debug, Clone, Default)]
235pub struct PackageQuery {
236    pub query: Option<String>,
237    pub id: Option<String>,
238    pub name: Option<String>,
239    pub moniker: Option<String>,
240    pub tag: Option<String>,
241    pub command: Option<String>,
242    pub source: Option<String>,
243    pub count: Option<usize>,
244    pub exact: bool,
245    pub version: Option<String>,
246    pub channel: Option<String>,
247    pub locale: Option<String>,
248    pub installer_type: Option<String>,
249    pub installer_architecture: Option<String>,
250    pub platform: Option<String>,
251    pub os_version: Option<String>,
252    pub install_scope: Option<String>,
253}
254
255#[derive(Debug, Clone, Default)]
256pub struct ListQuery {
257    pub query: Option<String>,
258    pub id: Option<String>,
259    pub name: Option<String>,
260    pub moniker: Option<String>,
261    pub tag: Option<String>,
262    pub command: Option<String>,
263    pub product_code: Option<String>,
264    pub version: Option<String>,
265    pub source: Option<String>,
266    pub count: Option<usize>,
267    pub exact: bool,
268    pub install_scope: Option<String>,
269    pub upgrade_only: bool,
270    pub include_unknown: bool,
271    pub include_pinned: bool,
272}
273
274#[derive(Debug, Clone, serde::Serialize)]
275#[serde(rename_all = "PascalCase")]
276pub struct SearchMatch {
277    pub source_name: String,
278    pub source_kind: SourceKind,
279    pub id: String,
280    pub name: String,
281    pub moniker: Option<String>,
282    pub version: Option<String>,
283    pub channel: Option<String>,
284    pub match_criteria: Option<String>,
285}
286
287#[derive(Debug, Clone, serde::Serialize)]
288#[serde(rename_all = "PascalCase")]
289pub struct SearchResponse {
290    pub matches: Vec<SearchMatch>,
291    pub warnings: Vec<String>,
292    pub truncated: bool,
293}
294
295#[derive(Debug, Clone, serde::Serialize)]
296#[serde(rename_all = "PascalCase")]
297pub struct ListMatch {
298    pub name: String,
299    pub id: String,
300    pub local_id: String,
301    pub installed_version: String,
302    pub available_version: Option<String>,
303    pub source_name: Option<String>,
304    pub publisher: Option<String>,
305    pub scope: Option<String>,
306    pub installer_category: Option<String>,
307    pub install_location: Option<String>,
308    pub package_family_names: Vec<String>,
309    pub product_codes: Vec<String>,
310    pub upgrade_codes: Vec<String>,
311}
312
313#[derive(Debug, Clone, serde::Serialize)]
314#[serde(rename_all = "PascalCase")]
315pub struct ListResponse {
316    pub matches: Vec<ListMatch>,
317    pub warnings: Vec<String>,
318    pub truncated: bool,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
322#[serde(rename_all = "PascalCase")]
323pub struct VersionKey {
324    pub version: String,
325    pub channel: String,
326}
327
328#[derive(Debug, Clone, serde::Serialize)]
329pub struct Manifest {
330    pub id: String,
331    pub name: String,
332    pub version: String,
333    pub channel: String,
334    pub publisher: Option<String>,
335    pub description: Option<String>,
336    pub moniker: Option<String>,
337    pub package_url: Option<String>,
338    pub publisher_url: Option<String>,
339    pub publisher_support_url: Option<String>,
340    pub license: Option<String>,
341    pub license_url: Option<String>,
342    pub privacy_url: Option<String>,
343    pub author: Option<String>,
344    pub copyright: Option<String>,
345    pub copyright_url: Option<String>,
346    pub release_notes: Option<String>,
347    pub release_notes_url: Option<String>,
348    pub tags: Vec<String>,
349    pub agreements: Vec<PackageAgreement>,
350    pub package_dependencies: Vec<String>,
351    pub documentation: Vec<Documentation>,
352    pub icons: Vec<PackageIcon>,
353    pub installers: Vec<Installer>,
354    // `RequireExplicitUpgrade: true` opts a package out of bulk
355    // `pinget upgrade` output (winget parity). Users can still upgrade by
356    // explicit `id`. Set at top-level or per-installer; treated as true
357    // when any installer asserts it.
358    pub require_explicit_upgrade: bool,
359}
360
361impl Manifest {
362    pub fn icon(&self) -> Option<&PackageIcon> {
363        select_best_icon(&self.icons)
364    }
365
366    pub fn icon_url(&self) -> Option<&str> {
367        self.icon().and_then(|icon| icon.icon_url.as_deref())
368    }
369}
370
371#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
372#[serde(rename_all = "PascalCase")]
373pub struct PackageIcon {
374    pub icon_url: Option<String>,
375    pub icon_file_type: Option<String>,
376    pub icon_resolution: Option<String>,
377    pub icon_theme: Option<String>,
378    pub icon_sha256: Option<String>,
379}
380
381impl PackageIcon {
382    fn is_empty(&self) -> bool {
383        self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty())
384            && self
385                .icon_file_type
386                .as_deref()
387                .is_none_or(|value| value.trim().is_empty())
388            && self
389                .icon_resolution
390                .as_deref()
391                .is_none_or(|value| value.trim().is_empty())
392            && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty())
393            && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty())
394    }
395}
396
397fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> {
398    icons
399        .iter()
400        .enumerate()
401        .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty()))
402        .max_by(|(left_index, left), (right_index, right)| {
403            icon_theme_score(left.icon_theme.as_deref())
404                .cmp(&icon_theme_score(right.icon_theme.as_deref()))
405                .then_with(|| {
406                    icon_resolution_score(left.icon_resolution.as_deref())
407                        .cmp(&icon_resolution_score(right.icon_resolution.as_deref()))
408                })
409                .then_with(|| {
410                    icon_file_type_score(left.icon_file_type.as_deref())
411                        .cmp(&icon_file_type_score(right.icon_file_type.as_deref()))
412                })
413                .then_with(|| right_index.cmp(left_index))
414        })
415        .map(|(_, icon)| icon)
416}
417
418fn icon_theme_score(theme: Option<&str>) -> usize {
419    match theme {
420        None => 1,
421        Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1,
422        Some(_) => 0,
423    }
424}
425
426fn icon_file_type_score(file_type: Option<&str>) -> usize {
427    match file_type {
428        Some(value) if value.eq_ignore_ascii_case("ico") => 2,
429        Some(value) if value.eq_ignore_ascii_case("png") => 1,
430        _ => 0,
431    }
432}
433
434fn icon_resolution_score(resolution: Option<&str>) -> usize {
435    let Some(resolution) = resolution else {
436        return 0;
437    };
438    let resolution = resolution.trim();
439    let Some(separator) = resolution.find(['x', 'X']) else {
440        return 0;
441    };
442    let (width, height) = resolution.split_at(separator);
443    let height = &height[1..];
444    let (Ok(width), Ok(height)) = (width.parse::<usize>(), height.parse::<usize>()) else {
445        return 0;
446    };
447    if width == height { width } else { 0 }
448}
449
450#[derive(Debug, Clone, serde::Serialize)]
451pub struct Documentation {
452    pub label: Option<String>,
453    pub url: String,
454}
455
456#[derive(Debug, Clone, serde::Serialize)]
457pub struct PackageAgreement {
458    pub label: Option<String>,
459    pub text: Option<String>,
460    pub url: Option<String>,
461}
462
463#[derive(Debug, Clone, Default, serde::Serialize)]
464pub struct Installer {
465    pub architecture: Option<String>,
466    pub installer_type: Option<String>,
467    pub nested_installer_type: Option<String>,
468    pub nested_installer_files: Vec<NestedInstallerFile>,
469    pub url: Option<String>,
470    pub sha256: Option<String>,
471    pub product_code: Option<String>,
472    pub locale: Option<String>,
473    pub scope: Option<String>,
474    pub release_date: Option<String>,
475    pub package_family_name: Option<String>,
476    pub upgrade_code: Option<String>,
477    pub platforms: Vec<String>,
478    pub minimum_os_version: Option<String>,
479    pub switches: InstallerSwitches,
480    pub commands: Vec<String>,
481    pub package_dependencies: Vec<String>,
482    pub require_explicit_upgrade: bool,
483}
484
485#[derive(Debug, Clone, Default, serde::Serialize)]
486pub struct NestedInstallerFile {
487    /// Path to the executable within the extracted archive.
488    pub relative_file_path: String,
489    /// Optional alias winget exposes for portable invocation. Pinget records it
490    /// for parity and uses it during portable installation to create a
491    /// `Links\\<alias>.exe` symlink; `Links\\` may also be added to the user PATH.
492    pub portable_command_alias: Option<String>,
493}
494
495#[derive(Debug, Clone, Default, serde::Serialize)]
496pub struct InstallerSwitches {
497    pub silent: Option<String>,
498    pub silent_with_progress: Option<String>,
499    pub interactive: Option<String>,
500    pub custom: Option<String>,
501    pub log: Option<String>,
502    pub install_location: Option<String>,
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
506pub enum InstallerMode {
507    Interactive,
508    SilentWithProgress,
509    Silent,
510}
511
512#[derive(Debug, Clone)]
513pub struct InstallRequest {
514    pub query: PackageQuery,
515    pub manifest_path: Option<PathBuf>,
516    pub mode: InstallerMode,
517    pub log_path: Option<PathBuf>,
518    pub custom: Option<String>,
519    pub override_args: Option<String>,
520    pub install_location: Option<String>,
521    pub skip_dependencies: bool,
522    pub dependencies_only: bool,
523    pub accept_package_agreements: bool,
524    pub force: bool,
525    pub rename: Option<String>,
526    pub uninstall_previous: bool,
527    pub ignore_security_hash: bool,
528    pub dependency_source: Option<String>,
529    pub no_upgrade: bool,
530}
531
532impl InstallRequest {
533    pub fn new(query: PackageQuery) -> Self {
534        Self {
535            query,
536            manifest_path: None,
537            mode: InstallerMode::SilentWithProgress,
538            log_path: None,
539            custom: None,
540            override_args: None,
541            install_location: None,
542            skip_dependencies: false,
543            dependencies_only: false,
544            accept_package_agreements: false,
545            force: false,
546            rename: None,
547            uninstall_previous: false,
548            ignore_security_hash: false,
549            dependency_source: None,
550            no_upgrade: false,
551        }
552    }
553}
554
555#[derive(Debug, Clone)]
556pub struct UninstallRequest {
557    pub query: PackageQuery,
558    pub manifest_path: Option<PathBuf>,
559    pub product_code: Option<String>,
560    pub mode: InstallerMode,
561    pub all_versions: bool,
562    pub force: bool,
563    pub purge: bool,
564    pub preserve: bool,
565    pub log_path: Option<PathBuf>,
566}
567
568impl UninstallRequest {
569    pub fn new(query: PackageQuery) -> Self {
570        Self {
571            query,
572            manifest_path: None,
573            product_code: None,
574            mode: InstallerMode::SilentWithProgress,
575            all_versions: false,
576            force: false,
577            purge: false,
578            preserve: false,
579            log_path: None,
580        }
581    }
582}
583
584#[derive(Debug, Clone)]
585pub struct RepairRequest {
586    pub query: PackageQuery,
587    pub manifest_path: Option<PathBuf>,
588    pub product_code: Option<String>,
589    pub mode: InstallerMode,
590    pub log_path: Option<PathBuf>,
591    pub accept_package_agreements: bool,
592    pub force: bool,
593    pub ignore_security_hash: bool,
594}
595
596impl RepairRequest {
597    pub fn new(query: PackageQuery) -> Self {
598        Self {
599            query,
600            manifest_path: None,
601            product_code: None,
602            mode: InstallerMode::SilentWithProgress,
603            log_path: None,
604            accept_package_agreements: false,
605            force: false,
606            ignore_security_hash: false,
607        }
608    }
609}
610
611#[derive(Debug, Clone, serde::Serialize)]
612pub struct ShowResult {
613    pub package: SearchMatch,
614    pub manifest: Manifest,
615    pub selected_installer: Option<Installer>,
616    pub cached_files: Vec<PathBuf>,
617    pub warnings: Vec<String>,
618    #[serde(skip_serializing)]
619    manifest_documents: JsonValue,
620}
621
622impl ShowResult {
623    pub fn structured_document(&self) -> JsonValue {
624        add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest)
625    }
626}
627
628/// Result of a `download` operation. In addition to the resolved installer
629/// file on disk, pinget mirrors `winget download` by writing a flattened YAML
630/// manifest next to the installer so users get a self-contained snapshot of
631/// the package version they downloaded.
632#[derive(Debug, Clone, serde::Serialize)]
633pub struct DownloadOutput {
634    pub manifest: Manifest,
635    pub installer_path: PathBuf,
636    pub manifest_path: PathBuf,
637}
638
639/// Internal result of fetching only the installer file. Used by the install
640/// flow which downloads into a temp dir and intentionally skips the manifest
641/// YAML side effect.
642struct FetchedInstaller {
643    manifest: Manifest,
644    manifest_documents: JsonValue,
645    installer: Installer,
646    installer_path: PathBuf,
647}
648
649#[derive(Debug, Clone, serde::Serialize)]
650#[serde(rename_all = "PascalCase")]
651pub struct VersionsResult {
652    pub package: SearchMatch,
653    pub versions: Vec<VersionKey>,
654    pub warnings: Vec<String>,
655}
656
657#[derive(Debug, Clone, serde::Serialize)]
658#[serde(rename_all = "PascalCase")]
659pub struct CacheWarmResult {
660    pub package: SearchMatch,
661    pub cached_files: Vec<PathBuf>,
662    pub warnings: Vec<String>,
663}
664
665#[derive(Debug, Clone, serde::Serialize)]
666#[serde(rename_all = "PascalCase")]
667pub struct SourceUpdateResult {
668    pub name: String,
669    pub kind: SourceKind,
670    pub detail: String,
671}
672
673#[derive(Debug, Clone, serde::Serialize)]
674#[serde(rename_all = "PascalCase")]
675pub struct PinRecord {
676    pub package_id: String,
677    pub version: String,
678    pub source_id: String,
679    pub pin_type: PinType,
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
683pub enum PinType {
684    Pinning,
685    Blocking,
686    Gating,
687}
688
689impl Display for PinType {
690    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
691        match self {
692            PinType::Pinning => f.write_str("Pinning"),
693            PinType::Blocking => f.write_str("Blocking"),
694            PinType::Gating => f.write_str("Gating"),
695        }
696    }
697}
698
699/// Result of an install/uninstall/upgrade operation
700#[derive(Debug, Clone, serde::Serialize)]
701pub struct InstallResult {
702    pub package_id: String,
703    pub version: String,
704    pub installer_path: PathBuf,
705    pub installer_type: String,
706    pub exit_code: i32,
707    pub success: bool,
708    pub no_op: bool,
709    pub warnings: Vec<String>,
710}
711
712#[derive(Debug, Clone)]
713pub enum InstallProgress {
714    ResolvingManifest,
715    ResolvedManifest { package_id: String, version: String },
716    CheckingInstalled,
717    InstallingDependencies,
718    SelectingInstaller,
719    DownloadingInstaller { url: String, path: PathBuf },
720    VerifyingInstaller { path: PathBuf },
721    StartingInstaller { installer_type: String, path: PathBuf },
722    WaitingForInstaller { installer_type: String },
723    InstallerFinished { exit_code: i32 },
724}
725
726impl InstallerSwitches {
727    fn with_fallback(&self, fallback: &Self) -> Self {
728        Self {
729            silent: self.silent.clone().or_else(|| fallback.silent.clone()),
730            silent_with_progress: self
731                .silent_with_progress
732                .clone()
733                .or_else(|| fallback.silent_with_progress.clone()),
734            interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
735            custom: self.custom.clone().or_else(|| fallback.custom.clone()),
736            log: self.log.clone().or_else(|| fallback.log.clone()),
737            install_location: self
738                .install_location
739                .clone()
740                .or_else(|| fallback.install_location.clone()),
741        }
742    }
743}
744
745fn installed_package_discovery_supported() -> bool {
746    cfg!(windows)
747}
748
749fn package_actions_supported() -> bool {
750    cfg!(windows)
751}
752
753fn unsupported_action_result(
754    package_id: impl Into<String>,
755    version: impl Into<String>,
756    installer_type: impl Into<String>,
757    warning: &str,
758) -> InstallResult {
759    InstallResult {
760        package_id: package_id.into(),
761        version: version.into(),
762        installer_path: PathBuf::new(),
763        installer_type: installer_type.into(),
764        exit_code: 0,
765        success: true,
766        no_op: true,
767        warnings: vec![warning.to_owned()],
768    }
769}
770
771#[derive(Debug, Clone)]
772struct InstalledPackage {
773    name: String,
774    local_id: String,
775    installed_version: String,
776    publisher: Option<String>,
777    scope: Option<String>,
778    installer_category: Option<String>,
779    install_location: Option<String>,
780    winget_package_identifier: Option<String>,
781    winget_source_identifier: Option<String>,
782    package_family_names: Vec<String>,
783    product_codes: Vec<String>,
784    upgrade_codes: Vec<String>,
785    correlated: Option<SearchMatch>,
786    // True when `installed_version` was rewritten to a catalog Version via
787    // versionData.mszyml's aMiV/aMaV range. Such versions compare meaningfully
788    // against the catalog's Version; raw ARP DisplayVersions usually don't.
789    // Used by dedupe so that, when the same id has both a canonical and a raw
790    // install row, we prefer the canonical (e.g. `Microsoft.DotNet.SDK.10`
791    // 10.0.108 over the VS-installed 40.10.18029 that doesn't map).
792    installed_version_canonical: bool,
793    // True when the correlated catalog package's latest version sets
794    // `RequireExplicitUpgrade: true`. winget hides those rows from bulk
795    // `upgrade`; we mirror that. Users can still upgrade by explicit id.
796    correlated_requires_explicit_upgrade: bool,
797    // True when the correlated catalog package's latest version has no
798    // installer for an architecture the user can actually run. winget
799    // hides those from `upgrade`; we mirror that.
800    correlated_lacks_compatible_installer: bool,
801}
802
803#[derive(Debug, Clone)]
804struct LocatedMatch {
805    display: SearchMatch,
806    source_index: usize,
807    locator: MatchLocator,
808}
809
810#[derive(Debug)]
811struct SearchSourceMatches {
812    matches: Vec<LocatedMatch>,
813    truncated: bool,
814}
815
816#[derive(Debug, Clone)]
817struct SourceSearchFailure {
818    source_name: String,
819    message: String,
820}
821
822#[derive(Debug)]
823struct SearchLocatedResult {
824    matches: Vec<LocatedMatch>,
825    warnings: Vec<String>,
826    failures: Vec<SourceSearchFailure>,
827    truncated: bool,
828}
829
830fn single_match_from_located(
831    located: SearchLocatedResult,
832    report_first_source_failure: bool,
833) -> Result<(LocatedMatch, Vec<String>)> {
834    if located.matches.is_empty() {
835        if report_first_source_failure && let Some(failure) = located.failures.first() {
836            bail!("failed to search source '{}': {}", failure.source_name, failure.message);
837        }
838        bail!("no package matched the supplied query");
839    }
840
841    if located.matches.len() > 1 {
842        let choices = located
843            .matches
844            .iter()
845            .take(10)
846            .map(|item| {
847                format!(
848                    "{} [{}] ({})",
849                    item.display.name, item.display.id, item.display.source_name
850                )
851            })
852            .collect::<Vec<_>>()
853            .join(", ");
854        bail!("multiple packages matched: {choices}");
855    }
856
857    Ok((located.matches.into_iter().next().expect("one match"), located.warnings))
858}
859
860fn should_retry_show_across_system_winget_sources(
861    uses_system_winget_sources: bool,
862    query: &PackageQuery,
863    located: &SearchLocatedResult,
864) -> bool {
865    uses_system_winget_sources
866        && query
867            .source
868            .as_deref()
869            .is_some_and(|source| source.eq_ignore_ascii_case("winget"))
870        && located.matches.is_empty()
871        && located.failures.is_empty()
872}
873
874#[derive(Debug, Clone)]
875enum MatchLocator {
876    PreIndexedV1 {
877        package_rowid: i64,
878    },
879    PreIndexedV2 {
880        package_rowid: i64,
881        package_hash: String,
882    },
883    Rest {
884        package_id: String,
885        versions: Vec<VersionKey>,
886    },
887}
888
889#[derive(Debug, Clone, Copy, PartialEq, Eq)]
890enum SearchSemantics {
891    Many,
892    Single,
893}
894
895#[derive(Debug, Clone, Serialize, Deserialize)]
896struct RestInfoCache {
897    expires_at: DateTime<Utc>,
898    value: RestInformation,
899}
900
901#[derive(Debug, Clone, Default, Serialize, Deserialize)]
902struct RestInformation {
903    #[serde(rename = "SourceIdentifier")]
904    source_identifier: String,
905    #[serde(rename = "ServerSupportedVersions", default)]
906    server_supported_versions: Vec<String>,
907    #[serde(rename = "RequiredPackageMatchFields", default)]
908    required_package_match_fields: Vec<String>,
909    #[serde(rename = "UnsupportedPackageMatchFields", default)]
910    unsupported_package_match_fields: Vec<String>,
911    #[serde(rename = "RequiredQueryParameters", default)]
912    required_query_parameters: Vec<String>,
913    #[serde(rename = "UnsupportedQueryParameters", default)]
914    unsupported_query_parameters: Vec<String>,
915}
916
917#[derive(Debug, Clone, Deserialize)]
918struct PackageVersionDataDocument {
919    #[serde(rename = "vD", default)]
920    versions: Vec<PackageVersionDataEntry>,
921}
922
923#[derive(Debug, Clone, Deserialize)]
924struct PackageVersionDataEntry {
925    #[serde(rename = "v")]
926    version: String,
927    #[serde(rename = "rP")]
928    manifest_relative_path: String,
929    #[serde(rename = "s256H")]
930    manifest_hash: String,
931    // ARP version range covered by this catalog version. WinGet writes these
932    // into versionData.mszyml so an installed ARP DisplayVersion (which may be
933    // an MSI build number with no relation to the catalog Version) can be
934    // mapped back to the catalog Version it corresponds to.
935    #[serde(rename = "aMiV", default)]
936    arp_min_version: Option<String>,
937    #[serde(rename = "aMaV", default)]
938    arp_max_version: Option<String>,
939}
940
941pub struct Repository {
942    app_root: PathBuf,
943    user_agent: String,
944    client: Client,
945    store: SourceStore,
946    source_mode: EffectiveSourceMode,
947    install_progress: Option<fn(&InstallProgress)>,
948    pre_indexed_source_auto_update_interval: Option<Duration>,
949    preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
950}
951
952#[derive(Debug, Clone)]
953struct PreindexedRefreshAttempt {
954    attempted_at: DateTime<Utc>,
955    succeeded: bool,
956}
957
958#[derive(Debug, Clone, Default)]
959struct PreindexedRefreshOutcome {
960    attempted: bool,
961    reused_recent_attempt: bool,
962    error: Option<String>,
963}
964
965struct PreindexedOpenConnection {
966    connection: Connection,
967    refresh: PreindexedRefreshOutcome,
968}
969
970#[derive(Debug)]
971struct MissingPackageVersionError {
972    package_id: String,
973    source_name: String,
974    requested_version: String,
975    requested_channel: Option<String>,
976}
977
978impl Display for MissingPackageVersionError {
979    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
980        let channel = self
981            .requested_channel
982            .as_deref()
983            .filter(|value| !value.is_empty())
984            .map(|value| format!(" with channel '{value}'"))
985            .unwrap_or_default();
986        write!(
987            f,
988            "Version '{}'{} was not found for package '{}' in source '{}'.",
989            self.requested_version, channel, self.package_id, self.source_name
990        )
991    }
992}
993
994impl std::error::Error for MissingPackageVersionError {}
995
996fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
997    static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
998    LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
999}
1000
1001impl Repository {
1002    /// Opens the repository with the default per-user CLI storage layout.
1003    pub fn open() -> Result<Self> {
1004        Self::open_with_options(RepositoryOptions::for_current_user()?)
1005    }
1006
1007    /// Opens the repository with explicit hosting options for library consumers.
1008    pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
1009        ensure_app_dirs(&options.app_root)?;
1010        let (source_mode, store) = load_effective_source_store(&options.app_root, options.source_mode)?;
1011        let client = Client::builder()
1012            .user_agent(&options.user_agent)
1013            .timeout(StdDuration::from_secs(30 * 60))
1014            .build()
1015            .context("failed to build HTTP client")?;
1016        Ok(Self {
1017            app_root: options.app_root,
1018            user_agent: options.user_agent,
1019            client,
1020            store,
1021            source_mode,
1022            install_progress: options.install_progress,
1023            pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
1024            preindexed_refresh_attempts: HashMap::new(),
1025        })
1026    }
1027
1028    fn uses_system_winget_sources(&self) -> bool {
1029        self.source_mode != EffectiveSourceMode::Private
1030    }
1031
1032    fn mirrors_system_winget_sources(&self) -> bool {
1033        self.source_mode == EffectiveSourceMode::SystemWingetMirror
1034    }
1035
1036    fn emit_install_progress(&self, progress: InstallProgress) {
1037        if let Some(callback) = self.install_progress {
1038            callback(&progress);
1039        }
1040    }
1041
1042    pub fn app_root(&self) -> &Path {
1043        &self.app_root
1044    }
1045
1046    pub fn list_sources(&self) -> Vec<SourceRecord> {
1047        self.store.sources.clone()
1048    }
1049
1050    pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
1051        self.add_source_with_metadata(name, arg, kind, None, false, 0)
1052    }
1053
1054    pub fn add_source_with_metadata(
1055        &mut self,
1056        name: &str,
1057        arg: &str,
1058        kind: SourceKind,
1059        trust_level: Option<&str>,
1060        explicit: bool,
1061        priority: i32,
1062    ) -> Result<()> {
1063        if self.uses_system_winget_sources() {
1064            system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
1065            self.refresh_system_winget_sources()?;
1066            return Ok(());
1067        }
1068
1069        if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
1070            bail!("A source with name '{name}' already exists.");
1071        }
1072        if self.store.sources.iter().any(|s| s.arg == arg) {
1073            bail!("A source with argument '{arg}' already exists.");
1074        }
1075        let identifier = name.to_owned();
1076        self.store.sources.push(SourceRecord {
1077            name: name.to_owned(),
1078            kind,
1079            arg: arg.to_owned(),
1080            identifier,
1081            trust_level: normalize_source_trust_level(trust_level)?,
1082            explicit,
1083            priority,
1084            last_update: None,
1085            source_version: None,
1086            etag: None,
1087            last_modified: None,
1088        });
1089        if self.source_mode != EffectiveSourceMode::SystemWingetDirect {
1090            self.save_store()?;
1091        }
1092        Ok(())
1093    }
1094
1095    pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
1096        if self.uses_system_winget_sources() {
1097            bail!("Editing system WinGet sources is not supported by winget source commands.");
1098        }
1099
1100        let source = self
1101            .store
1102            .sources
1103            .iter_mut()
1104            .find(|source| source.name.eq_ignore_ascii_case(name))
1105            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1106
1107        if let Some(explicit) = explicit {
1108            source.explicit = explicit;
1109        }
1110
1111        if trust_level.is_some() {
1112            source.trust_level = normalize_source_trust_level(trust_level)?;
1113        }
1114
1115        self.save_store()?;
1116        Ok(())
1117    }
1118
1119    pub fn remove_source(&mut self, name: &str) -> Result<()> {
1120        if self.uses_system_winget_sources() {
1121            system_winget_remove_source(name)?;
1122            self.refresh_system_winget_sources()?;
1123            return Ok(());
1124        }
1125
1126        let idx = self
1127            .store
1128            .sources
1129            .iter()
1130            .position(|s| s.name.eq_ignore_ascii_case(name))
1131            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1132        let source = self.store.sources.remove(idx);
1133        // Clean up cached state for this source
1134        let state_dir = self.source_state_dir(&source);
1135        if state_dir.exists() {
1136            let _ = fs::remove_dir_all(&state_dir);
1137        }
1138        self.save_store()?;
1139        Ok(())
1140    }
1141
1142    pub fn reset_source(&mut self, name: &str) -> Result<()> {
1143        if self.uses_system_winget_sources() {
1144            system_winget_reset_source(name)?;
1145            self.refresh_system_winget_sources()?;
1146            return Ok(());
1147        }
1148
1149        let idx = self
1150            .store
1151            .sources
1152            .iter()
1153            .position(|source| source.name.eq_ignore_ascii_case(name))
1154            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1155
1156        let source = self.store.sources[idx].clone();
1157        let state_dir = self.source_state_dir(&source);
1158        if state_dir.exists() {
1159            let _ = fs::remove_dir_all(&state_dir);
1160        }
1161
1162        if let Some(default_source) = SourceStore::default()
1163            .sources
1164            .into_iter()
1165            .find(|source| source.name.eq_ignore_ascii_case(name))
1166        {
1167            self.store.sources[idx] = default_source;
1168        } else {
1169            self.store.sources[idx].last_update = None;
1170            self.store.sources[idx].source_version = None;
1171            self.store.sources[idx].etag = None;
1172            self.store.sources[idx].last_modified = None;
1173        }
1174
1175        self.save_store()?;
1176        Ok(())
1177    }
1178
1179    pub fn reset_sources(&mut self) -> Result<()> {
1180        if self.uses_system_winget_sources() {
1181            system_winget_reset_sources()?;
1182            self.refresh_system_winget_sources()?;
1183            return Ok(());
1184        }
1185
1186        // Remove cached state for all current sources
1187        for source in &self.store.sources {
1188            let state_dir = self.source_state_dir(source);
1189            if state_dir.exists() {
1190                let _ = fs::remove_dir_all(&state_dir);
1191            }
1192        }
1193        self.store = SourceStore::default();
1194        self.save_store()?;
1195        Ok(())
1196    }
1197
1198    pub fn supported_admin_settings() -> &'static [&'static str] {
1199        SUPPORTED_ADMIN_SETTINGS
1200    }
1201
1202    pub fn get_user_settings(&self) -> Result<JsonValue> {
1203        Ok(JsonValue::Object(load_json_object(&user_settings_path(
1204            &self.app_root,
1205        ))?))
1206    }
1207
1208    pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
1209        let update = match user_settings {
1210            JsonValue::Object(object) => object.clone(),
1211            _ => bail!("user settings must be a JSON object"),
1212        };
1213
1214        let effective = if merge {
1215            merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
1216        } else {
1217            update
1218        };
1219        save_json_object(user_settings_path(&self.app_root), &effective)?;
1220        Ok(JsonValue::Object(effective))
1221    }
1222
1223    pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
1224        let current = self.get_user_settings()?;
1225        Ok(if ignore_not_set {
1226            json_contains(&current, expected)
1227        } else {
1228            current == *expected
1229        })
1230    }
1231
1232    pub fn get_admin_settings(&self) -> Result<JsonValue> {
1233        let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
1234        for name in SUPPORTED_ADMIN_SETTINGS {
1235            settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
1236        }
1237
1238        Ok(JsonValue::Object(settings))
1239    }
1240
1241    pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1242        let normalized = normalize_admin_setting_name(name)?;
1243        let mut settings = match self.get_admin_settings()? {
1244            JsonValue::Object(settings) => settings,
1245            _ => JsonMap::new(),
1246        };
1247        settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1248        save_json_object(admin_settings_path(&self.app_root), &settings)
1249    }
1250
1251    pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1252        if !reset_all && name.is_none() {
1253            bail!("resetting admin settings requires a setting name or reset-all");
1254        }
1255
1256        let mut settings = match self.get_admin_settings()? {
1257            JsonValue::Object(settings) => settings,
1258            _ => JsonMap::new(),
1259        };
1260
1261        if reset_all {
1262            for setting_name in SUPPORTED_ADMIN_SETTINGS {
1263                settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1264            }
1265        } else {
1266            settings.insert(
1267                normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1268                JsonValue::Bool(false),
1269            );
1270        }
1271
1272        save_json_object(admin_settings_path(&self.app_root), &settings)
1273    }
1274
1275    pub fn ensure_settings_files(&self) -> Result<()> {
1276        let user_settings = user_settings_path(&self.app_root);
1277        if !user_settings.exists() {
1278            save_json_object(user_settings, &JsonMap::new())?;
1279        }
1280
1281        let admin_settings = admin_settings_path(&self.app_root);
1282        if !admin_settings.exists() {
1283            let mut values = JsonMap::new();
1284            for name in SUPPORTED_ADMIN_SETTINGS {
1285                values.insert((*name).to_owned(), JsonValue::Bool(false));
1286            }
1287            save_json_object(admin_settings, &values)?;
1288        }
1289
1290        Ok(())
1291    }
1292
1293    pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1294        if self.source_mode == EffectiveSourceMode::SystemWingetDirect {
1295            return self.update_system_winget_sources(source_name);
1296        }
1297
1298        if self.mirrors_system_winget_sources() {
1299            self.refresh_system_winget_sources()?;
1300        }
1301
1302        let indexes = self.resolve_source_indexes(source_name)?;
1303        let mut results = Vec::new();
1304
1305        for index in indexes {
1306            let detail = match self.store.sources[index].kind {
1307                SourceKind::PreIndexed => self.update_preindexed(index)?,
1308                SourceKind::Rest => self.update_rest(index)?,
1309            };
1310
1311            results.push(SourceUpdateResult {
1312                name: self.store.sources[index].name.clone(),
1313                kind: self.store.sources[index].kind,
1314                detail,
1315            });
1316        }
1317
1318        self.save_store()?;
1319        Ok(results)
1320    }
1321
1322    pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1323        let located = self.search_located(query, SearchSemantics::Many)?;
1324        Ok(SearchResponse {
1325            matches: located.matches.into_iter().map(|item| item.display).collect(),
1326            warnings: located.warnings,
1327            truncated: located.truncated,
1328        })
1329    }
1330
1331    pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1332        let located = self.search_located(query, SearchSemantics::Many)?;
1333        let mut structured_documents = Vec::with_capacity(located.matches.len());
1334        for located in located.matches {
1335            let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1336            structured_documents.push(manifest_documents);
1337        }
1338
1339        Ok(collapse_structured_documents(&structured_documents))
1340    }
1341
1342    pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1343        if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1344            bail!("--include-unknown and --include-pinned require --upgrade-available");
1345        }
1346        if query.source.is_some()
1347            && !query.upgrade_only
1348            && query.query.is_none()
1349            && query.id.is_none()
1350            && query.name.is_none()
1351            && query.moniker.is_none()
1352            && query.tag.is_none()
1353            && query.command.is_none()
1354        {
1355            bail!("list --source currently requires a query or explicit filter");
1356        }
1357
1358        self.refresh_system_winget_sources()?;
1359
1360        let has_filter = list_query_needs_available_lookup(query);
1361        let needs_available = has_filter || query.upgrade_only;
1362
1363        let mut warnings = Vec::new();
1364        if !installed_package_discovery_supported() {
1365            warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1366        }
1367        let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1368
1369        // Plain `list` should still correlate installed packages back to their
1370        // catalog identities so callers see canonical package ids/source names.
1371        // Available-version lookups and loose name-only enrichment remain gated
1372        // by `needs_available` below.
1373        warnings.extend(self.correlate_installed_via_installed_db(&mut installed, query.source.as_deref())?);
1374        warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1375        warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1376
1377        if needs_available && has_filter {
1378            // Filtered lookup: search sources with the user's query for any
1379            // installed package not already resolved by identity.
1380            let available_query = package_query_from_list_query(query);
1381            let located = self.search_located(&available_query, SearchSemantics::Many)?;
1382            warnings.extend(located.warnings);
1383            let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1384            for package in &mut installed {
1385                if package.correlated.is_some() {
1386                    continue;
1387                }
1388                package.correlated =
1389                    correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1390            }
1391        } else if needs_available && !query.upgrade_only {
1392            // Plain unfiltered list: preserve legacy best-effort catalog
1393            // display/source enrichment. Bulk upgrade avoids this loose
1394            // name-only pass because winget only upgrades packages correlated
1395            // through stronger identity/normalization paths.
1396            warnings.extend(self.correlate_all_installed(&mut installed)?);
1397        }
1398
1399        if needs_available {
1400            // Enrich rows that correlated via the name-based fallback. Those
1401            // rows skipped the v2-index aMiV/aMaV pass that runs inside
1402            // `correlate_installed_via_index`, so they still carry the raw
1403            // ARP DisplayVersion (e.g. `JetBrains Rider 2025.3.0.1` showing
1404            // build `253.28294.112` instead of the marketing `2025.3.0.1`).
1405            // Also resolves MSIX placeholder names like
1406            // `ms-resource:appDisplayName` to the catalog's display name —
1407            // winget pulls these through Windows.Management.Deployment; the
1408            // catalog name is the parity-faithful substitute pinget has
1409            // available without taking a WinRT dependency.
1410            warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1411        }
1412
1413        // For `upgrade`, collapse multiple installed entries that map to the
1414        // same catalog package id (different ARP rows for side-by-side .NET
1415        // SDKs, several runtime versions of WindowsAppRuntime, the MSIX shim
1416        // alongside an ARP install of Edge, etc.). Keep the entry with the
1417        // highest installed_version so the upgrade comparison runs against
1418        // the user's newest install — matches winget's one-row-per-id output.
1419        if query.upgrade_only {
1420            installed = dedupe_correlated_for_upgrade(installed);
1421        }
1422
1423        let mut matches = installed
1424            .into_iter()
1425            .filter(|package| {
1426                list_package_matches(package, query)
1427                    && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1428            })
1429            .collect::<Vec<_>>();
1430
1431        matches.sort_by(|left, right| {
1432            list_sort_weight(left)
1433                .cmp(&list_sort_weight(right))
1434                .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1435                .then_with(|| left.local_id.cmp(&right.local_id))
1436        });
1437
1438        let pins = if query.upgrade_only {
1439            self.list_pins(query.source.as_deref())?
1440        } else {
1441            Vec::new()
1442        };
1443        let mut list_matches = matches
1444            .into_iter()
1445            .map(|package| list_match_from_installed(package, &self.store.sources))
1446            .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1447            .collect::<Vec<_>>();
1448        if !query.upgrade_only {
1449            list_matches = suppress_duplicate_available_versions(list_matches);
1450        }
1451        let truncated = if let Some(limit) = query.count {
1452            let was_truncated = list_matches.len() > limit;
1453            list_matches.truncate(limit);
1454            was_truncated
1455        } else {
1456            false
1457        };
1458
1459        Ok(ListResponse {
1460            matches: list_matches,
1461            warnings,
1462            truncated,
1463        })
1464    }
1465
1466    /// For unfiltered upgrade/list, search the entire available index and correlate
1467    /// against all installed packages. Skips packages already correlated via the
1468    /// v2 identity tables.
1469    fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1470        if installed.iter().all(|p| p.correlated.is_some()) {
1471            return Ok(Vec::new());
1472        }
1473        let all_query = PackageQuery {
1474            query: None,
1475            id: None,
1476            name: None,
1477            moniker: None,
1478            tag: None,
1479            command: None,
1480            source: None,
1481            count: Some(100_000), // fetch the entire index
1482            exact: false,
1483            version: None,
1484            channel: None,
1485            locale: None,
1486            installer_type: None,
1487            installer_architecture: None,
1488            platform: None,
1489            os_version: None,
1490            install_scope: None,
1491        };
1492        let located = self.search_located(&all_query, SearchSemantics::Many)?;
1493        let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1494
1495        for package in installed.iter_mut() {
1496            if package.correlated.is_some() {
1497                continue;
1498            }
1499            package.correlated = correlate_installed_package(package, &candidates, true);
1500        }
1501
1502        Ok(located.warnings)
1503    }
1504
1505    /// Uses winget's per-source installed tracking DB when available. This is
1506    /// an exact identity signal recorded by winget itself, and it preserves
1507    /// source order for sources that do not expose a preindexed identity table
1508    /// such as REST sources.
1509    fn correlate_installed_via_installed_db(
1510        &mut self,
1511        installed: &mut [InstalledPackage],
1512        requested_source: Option<&str>,
1513    ) -> Result<Vec<String>> {
1514        let source_indices = self
1515            .store
1516            .sources
1517            .iter()
1518            .enumerate()
1519            .filter(|(_, source)| {
1520                requested_source
1521                    .map(|name| source.name.eq_ignore_ascii_case(name))
1522                    .unwrap_or(true)
1523            })
1524            .map(|(index, _)| index)
1525            .collect::<Vec<_>>();
1526
1527        for source_index in source_indices {
1528            let source = self.source_clone(source_index);
1529            let installed_db_path = self.source_installed_db_path(&source);
1530            if !installed_db_path.exists() {
1531                continue;
1532            }
1533
1534            let connection = Self::open_sqlite_connection(installed_db_path)?;
1535            if !installed_db_identity_tables_present(&connection)? {
1536                continue;
1537            }
1538
1539            for package in installed.iter_mut() {
1540                if package.correlated.is_some() {
1541                    continue;
1542                }
1543                let Some((id, name, matched_by)) = lookup_installed_db_identity_match(&connection, package)? else {
1544                    continue;
1545                };
1546                let (id, name, moniker, version) = if source.kind == SourceKind::PreIndexed {
1547                    match self
1548                        .open_preindexed_connection(source_index)
1549                        .and_then(|index_connection| {
1550                            let Some(meta) = lookup_v2_metadata_by_id(&index_connection, &id)? else {
1551                                return Ok((id.clone(), name.clone(), None, None));
1552                            };
1553                            let (canonical_installed, anchored_latest) =
1554                                match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1555                                    Ok((entries, _)) => (
1556                                        map_arp_version_to_catalog(&entries, &package.installed_version)
1557                                            .or_else(|| {
1558                                                less_than_latest_arp_anchored_version(
1559                                                    &entries,
1560                                                    &package.installed_version,
1561                                                )
1562                                            })
1563                                            .or_else(|| {
1564                                                greater_than_latest_arp_anchored_version(
1565                                                    &entries,
1566                                                    &package.installed_version,
1567                                                    &meta.latest_version,
1568                                                )
1569                                            }),
1570                                        latest_arp_anchored_version(&entries),
1571                                    ),
1572                                    Err(_) => (None, None),
1573                                };
1574                            let version = match (&canonical_installed, anchored_latest) {
1575                                (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1576                                (Some(_), Some(anchored)) => Some(anchored),
1577                                (Some(_), None) => None,
1578                                _ => Some(meta.latest_version.clone()),
1579                            };
1580                            if let Some(version) = canonical_installed {
1581                                package.installed_version = version;
1582                                package.installed_version_canonical = true;
1583                            }
1584                            Ok((meta.id, meta.name, meta.moniker, version))
1585                        }) {
1586                        Ok(enriched) => enriched,
1587                        Err(_) => (id, name, None, None),
1588                    }
1589                } else {
1590                    (id, name, None, None)
1591                };
1592                package.correlated = Some(SearchMatch {
1593                    source_name: source.name.clone(),
1594                    source_kind: source.kind,
1595                    id,
1596                    name,
1597                    moniker,
1598                    version,
1599                    channel: None,
1600                    match_criteria: Some(matched_by.to_owned()),
1601                });
1602            }
1603        }
1604
1605        Ok(Vec::new())
1606    }
1607
1608    /// Correlates installed packages against the v2 pre-indexed catalog using
1609    /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's
1610    /// authoritative correlation path. Also rewrites `installed_version` to
1611    /// the catalog Version whose ARP range covers the ARP DisplayVersion, so
1612    /// packages whose ARP version is an MSI build number (e.g. .NET SDK,
1613    /// Python) compare against a meaningful catalog version. Packages without
1614    /// any of the three identifiers, or whose identifiers don't match a
1615    /// catalog entry, are left untouched for the name-based fallback.
1616    fn correlate_installed_via_index(
1617        &mut self,
1618        installed: &mut [InstalledPackage],
1619        requested_source: Option<&str>,
1620    ) -> Result<Vec<String>> {
1621        let source_indices: Vec<usize> = self
1622            .store
1623            .sources
1624            .iter()
1625            .enumerate()
1626            .filter(|(_, source)| {
1627                source.kind == SourceKind::PreIndexed
1628                    && match requested_source {
1629                        Some(name) => source.name.eq_ignore_ascii_case(name),
1630                        None => true,
1631                    }
1632            })
1633            .map(|(index, _)| index)
1634            .collect();
1635
1636        for source_index in source_indices {
1637            let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1638                let connection = match self.open_preindexed_connection(source_index) {
1639                    Ok(c) => c,
1640                    Err(_) => continue,
1641                };
1642                if !v2_identity_tables_present(&connection) {
1643                    continue;
1644                }
1645                let mut acc = Vec::new();
1646                for (idx, package) in installed.iter().enumerate() {
1647                    if package.correlated.is_some() {
1648                        continue;
1649                    }
1650                    let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1651                        continue;
1652                    };
1653                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1654                        continue;
1655                    };
1656                    acc.push((idx, meta, by));
1657                }
1658                acc
1659            };
1660
1661            let source = self.source_clone(source_index);
1662            for (idx, meta, by) in resolved {
1663                let (canonical_installed, anchored_latest) =
1664                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1665                        Ok((entries, _)) => (
1666                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1667                                .or_else(|| {
1668                                    less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1669                                })
1670                                .or_else(|| {
1671                                    greater_than_latest_arp_anchored_version(
1672                                        &entries,
1673                                        &installed[idx].installed_version,
1674                                        &meta.latest_version,
1675                                    )
1676                                }),
1677                            latest_arp_anchored_version(&entries),
1678                        ),
1679                        Err(_) => (None, None),
1680                    };
1681                // When the installed side was rebased to a catalog Version via
1682                // aMiV/aMaV, the available side must compare on the same scale:
1683                // prefer the latest ARP-anchored Version over `latest_version`,
1684                // which can be an internal/MSI build number (e.g. WinAppRuntime
1685                // 1.8's `8000.836.2153.0`) that would manufacture a phantom
1686                // upgrade against an installed canonical like `1.8.6`.
1687                let available_version = match (&canonical_installed, anchored_latest) {
1688                    (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1689                    (Some(_), Some(anchored)) => Some(anchored),
1690                    (Some(_), None) => None,
1691                    _ => Some(meta.latest_version),
1692                };
1693                let installed_pkg = &mut installed[idx];
1694                installed_pkg.correlated = Some(SearchMatch {
1695                    source_name: source.name.clone(),
1696                    source_kind: source.kind,
1697                    id: meta.id,
1698                    name: meta.name,
1699                    moniker: meta.moniker,
1700                    version: available_version,
1701                    channel: None,
1702                    match_criteria: Some(by.to_owned()),
1703                });
1704                if let Some(version) = canonical_installed {
1705                    installed_pkg.installed_version = version;
1706                    installed_pkg.installed_version_canonical = true;
1707                }
1708            }
1709        }
1710
1711        Ok(Vec::new())
1712    }
1713
1714    /// Mid-tier correlation that mirrors winget's ARP normalization path.
1715    /// For each installed package without a PFN/PC/UC identity match, the
1716    /// (DisplayName, Publisher) is run through the NameNormalizer and
1717    /// intersected against the v2 index's `norm_names2` and
1718    /// `norm_publishers2` tables. A single resulting `package_rowid`
1719    /// produces a correlation; ambiguous matches are skipped, matching
1720    /// winget's empty-Source behavior when normalization can't pick a
1721    /// winner.
1722    fn correlate_installed_by_normalized_identity(
1723        &mut self,
1724        installed: &mut [InstalledPackage],
1725        requested_source: Option<&str>,
1726    ) -> Result<Vec<String>> {
1727        let source_indices: Vec<usize> = self
1728            .store
1729            .sources
1730            .iter()
1731            .enumerate()
1732            .filter(|(_, source)| {
1733                source.kind == SourceKind::PreIndexed
1734                    && match requested_source {
1735                        Some(name) => source.name.eq_ignore_ascii_case(name),
1736                        None => true,
1737                    }
1738            })
1739            .map(|(index, _)| index)
1740            .collect();
1741
1742        for source_index in source_indices {
1743            let resolved: Vec<(usize, V2PackageMetadata)> = {
1744                let connection = match self.open_preindexed_connection(source_index) {
1745                    Ok(c) => c,
1746                    Err(_) => continue,
1747                };
1748                if !v2_normalized_identity_tables_present(&connection) {
1749                    continue;
1750                }
1751                let mut acc = Vec::new();
1752                for (idx, pkg) in installed.iter().enumerate() {
1753                    if pkg.correlated.is_some() {
1754                        continue;
1755                    }
1756                    // MSIX entries belong to the PFN-only path; skipping
1757                    // here mirrors `correlate_installed_package`'s
1758                    // hard-block on MSIX (their `ms-resource:` placeholder
1759                    // names normalize to garbage anyway).
1760                    if pkg.local_id.starts_with("MSIX\\") {
1761                        continue;
1762                    }
1763                    let Some(publisher) = pkg.publisher.as_deref() else {
1764                        continue;
1765                    };
1766                    let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1767                    if normalized_name.is_empty() {
1768                        continue;
1769                    }
1770                    let normalized_publisher = name_normalization::normalize_publisher(publisher);
1771                    if normalized_publisher.is_empty() {
1772                        continue;
1773                    }
1774                    let Some(rowid) =
1775                        lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1776                    else {
1777                        continue;
1778                    };
1779                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1780                        continue;
1781                    };
1782                    acc.push((idx, meta));
1783                }
1784                acc
1785            };
1786
1787            let source = self.source_clone(source_index);
1788            for (idx, meta) in resolved {
1789                let (canonical_installed, anchored_latest) =
1790                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1791                        Ok((entries, _)) => (
1792                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1793                                .or_else(|| {
1794                                    less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1795                                })
1796                                .or_else(|| {
1797                                    greater_than_latest_arp_anchored_version(
1798                                        &entries,
1799                                        &installed[idx].installed_version,
1800                                        &meta.latest_version,
1801                                    )
1802                                }),
1803                            latest_arp_anchored_version(&entries),
1804                        ),
1805                        Err(_) => (None, None),
1806                    };
1807                let available_version = match (&canonical_installed, anchored_latest) {
1808                    (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1809                    (Some(_), Some(anchored)) => Some(anchored),
1810                    (Some(_), None) => None,
1811                    _ => Some(meta.latest_version),
1812                };
1813                let installed_pkg = &mut installed[idx];
1814                installed_pkg.correlated = Some(SearchMatch {
1815                    source_name: source.name.clone(),
1816                    source_kind: source.kind,
1817                    id: meta.id,
1818                    name: meta.name,
1819                    moniker: meta.moniker,
1820                    version: available_version,
1821                    channel: None,
1822                    match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1823                });
1824                if let Some(version) = canonical_installed {
1825                    installed_pkg.installed_version = version;
1826                    installed_pkg.installed_version_canonical = true;
1827                }
1828            }
1829        }
1830
1831        Ok(Vec::new())
1832    }
1833
1834    /// Runs after both correlation paths to enrich already-correlated rows
1835    /// with information that the identity path applies inline but that
1836    /// name-based correlation skips:
1837    ///   • The MSIX-placeholder name fix — when an MSIX entry's installed
1838    ///     name is an unresolved Windows resource string (`ms-resource:...`)
1839    ///     winget renders the catalog's `PackageName` instead. We do the
1840    ///     same so display-name comparisons match.
1841    ///   • The aMiV/aMaV version remap — `JetBrains.Rider` has no
1842    ///     ProductCode/PFN/UpgradeCode so it correlates by name; without
1843    ///     this pass its installed version stays as the ARP build number
1844    ///     `253.28294.112` instead of the catalog Version `2025.3.0.1`.
1845    fn enrich_correlated_via_index(
1846        &mut self,
1847        installed: &mut [InstalledPackage],
1848        requested_source: Option<&str>,
1849    ) -> Result<Vec<String>> {
1850        // Cheap pass first — no SQL required.
1851        for package in installed.iter_mut() {
1852            apply_msix_resource_string_name_fix(package);
1853        }
1854
1855        // Expensive pass — load versionData for rows whose installed_version
1856        // wasn't remapped earlier.
1857        let source_indices: Vec<usize> = self
1858            .store
1859            .sources
1860            .iter()
1861            .enumerate()
1862            .filter(|(_, source)| {
1863                source.kind == SourceKind::PreIndexed
1864                    && match requested_source {
1865                        Some(name) => source.name.eq_ignore_ascii_case(name),
1866                        None => true,
1867                    }
1868            })
1869            .map(|(index, _)| index)
1870            .collect();
1871
1872        for source_index in source_indices {
1873            let needs: Vec<(usize, V2PackageMetadata)> = {
1874                let connection = match self.open_preindexed_connection(source_index) {
1875                    Ok(c) => c,
1876                    Err(_) => continue,
1877                };
1878                if !v2_identity_tables_present(&connection) {
1879                    continue;
1880                }
1881                let mut acc = Vec::new();
1882                for (idx, pkg) in installed.iter().enumerate() {
1883                    let Some(candidate) = pkg.correlated.as_ref() else {
1884                        continue;
1885                    };
1886                    // Stay within the source the row was correlated to —
1887                    // looking up `JetBrains.Rider` in a different catalog
1888                    // would either miss or pull the wrong package's range.
1889                    if !candidate
1890                        .source_name
1891                        .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1892                    {
1893                        continue;
1894                    }
1895                    let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1896                        continue;
1897                    };
1898                    acc.push((idx, meta));
1899                }
1900                acc
1901            };
1902
1903            let source = self.source_clone(source_index);
1904            for (idx, meta) in needs {
1905                // Load versionData once: we may need it for both the
1906                // aMiV/aMaV remap (only if installed_version isn't already
1907                // canonical) and to locate the latest manifest for the
1908                // RequireExplicitUpgrade flag.
1909                let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1910                    Ok((entries, _)) => entries,
1911                    Err(_) => continue,
1912                };
1913
1914                if !installed[idx].installed_version_canonical {
1915                    let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1916                        .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version))
1917                        .or_else(|| {
1918                            greater_than_latest_arp_anchored_version(
1919                                &entries,
1920                                &installed[idx].installed_version,
1921                                &meta.latest_version,
1922                            )
1923                        });
1924                    let anchored_latest = latest_arp_anchored_version(&entries);
1925                    if let Some(canonical) = canonical_installed {
1926                        let greater_than_latest = is_greater_than_version_marker(&canonical);
1927                        installed[idx].installed_version = canonical;
1928                        installed[idx].installed_version_canonical = true;
1929                        if let Some(correlated) = installed[idx].correlated.as_mut() {
1930                            correlated.version = if greater_than_latest { None } else { anchored_latest };
1931                        }
1932                    }
1933                }
1934
1935                // RequireExplicitUpgrade is set on the installer manifest
1936                // for the latest catalog version. winget hides these from
1937                // bulk `upgrade` (Edge, Steam, Discord and others). We
1938                // peek at the first versionData entry (latest by sort
1939                // order) and read its manifest to mirror that filtering.
1940                if let Some(latest) = entries.first() {
1941                    let bytes_result = self.get_cached_source_file(
1942                        "V2_M",
1943                        &source,
1944                        &latest.manifest_relative_path,
1945                        Some(latest.manifest_hash.as_str()),
1946                    );
1947                    if let Ok(bytes) = bytes_result
1948                        && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1949                    {
1950                        installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1951                        installed[idx].correlated_lacks_compatible_installer =
1952                            !manifest_has_compatible_installer(&manifest);
1953                    }
1954                }
1955            }
1956        }
1957
1958        Ok(Vec::new())
1959    }
1960
1961    pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1962        let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1963        let versions = self.versions_for_match(&located, query)?;
1964        Ok(VersionsResult {
1965            package: located.display,
1966            versions,
1967            warnings,
1968        })
1969    }
1970
1971    pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1972        let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
1973        let versions = self.versions_for_match(&located, query)?;
1974        Ok(VersionsResult {
1975            package: located.display,
1976            versions,
1977            warnings,
1978        })
1979    }
1980
1981    pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1982        let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
1983        let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1984        let selected_installer = select_installer(&manifest.installers, query);
1985
1986        Ok(ShowResult {
1987            package: located.display,
1988            manifest,
1989            selected_installer,
1990            cached_files,
1991            warnings,
1992            manifest_documents,
1993        })
1994    }
1995
1996    pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1997        let (located, warnings) = self.find_single_match(query)?;
1998        let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1999
2000        Ok(CacheWarmResult {
2001            package: located.display,
2002            cached_files,
2003            warnings,
2004        })
2005    }
2006
2007    // ── Pin management ──
2008
2009    pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
2010        let db_path = pins_db_path(&self.app_root);
2011        if !db_path.exists() {
2012            return Ok(Vec::new());
2013        }
2014        let conn = Self::open_sqlite_connection(db_path)?;
2015        let pin_type_column = match resolve_pin_type_column(&conn)? {
2016            Some(column) => column,
2017            None => return Ok(Vec::new()),
2018        };
2019        let sql = if source_id.is_some() {
2020            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
2021        } else {
2022            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
2023        };
2024        let params = if let Some(source_id) = source_id {
2025            vec![SqlValue::Text(source_id.to_owned())]
2026        } else {
2027            Vec::new()
2028        };
2029        query_rows(&conn, &sql, params, |row| {
2030            let pin_type_int = row_i64(row, 3)?;
2031            Ok(PinRecord {
2032                package_id: row_string(row, 0)?,
2033                version: row_string(row, 1)?,
2034                source_id: row_string(row, 2)?,
2035                pin_type: decode_pin_type(pin_type_int),
2036            })
2037        })
2038    }
2039
2040    pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
2041        let db_path = pins_db_path(&self.app_root);
2042        let conn = Self::open_sqlite_connection(db_path)?;
2043        execute_batch_sql(
2044            &conn,
2045            "CREATE TABLE IF NOT EXISTS pin (
2046                package_id TEXT NOT NULL,
2047                version TEXT NOT NULL DEFAULT '*',
2048                source_id TEXT NOT NULL DEFAULT '',
2049                type INTEGER NOT NULL DEFAULT 0,
2050                PRIMARY KEY (package_id, source_id)
2051            )",
2052        )?;
2053        let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
2054        let type_int: i64 = match pin_type {
2055            PinType::Pinning => 2,
2056            PinType::Blocking => 4,
2057            PinType::Gating => 3,
2058        };
2059        execute_sql(
2060            &conn,
2061            &format!(
2062                "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
2063            ),
2064            vec![
2065                SqlValue::Text(package_id.to_owned()),
2066                SqlValue::Text(version.to_owned()),
2067                SqlValue::Text(source_id.to_owned()),
2068                SqlValue::Integer(type_int),
2069            ],
2070        )?;
2071        conn.cacheflush().context("failed to flush pin database")?;
2072        Ok(())
2073    }
2074
2075    pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
2076        let db_path = pins_db_path(&self.app_root);
2077        if !db_path.exists() {
2078            return Ok(false);
2079        }
2080        let conn = Self::open_sqlite_connection(db_path)?;
2081        let count = if let Some(source_id) = source_id {
2082            execute_sql(
2083                &conn,
2084                "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
2085                vec![
2086                    SqlValue::Text(package_id.to_owned()),
2087                    SqlValue::Text(source_id.to_owned()),
2088                ],
2089            )?
2090        } else {
2091            execute_sql(
2092                &conn,
2093                "DELETE FROM pin WHERE package_id = ?1",
2094                vec![SqlValue::Text(package_id.to_owned())],
2095            )?
2096        };
2097        conn.cacheflush().context("failed to flush pin database")?;
2098        Ok(count > 0)
2099    }
2100
2101    pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
2102        let db_path = pins_db_path(&self.app_root);
2103        if !db_path.exists() {
2104            return Ok(());
2105        }
2106
2107        if let Some(source_id) = source_id {
2108            let conn = Self::open_sqlite_connection(db_path)?;
2109            execute_sql(
2110                &conn,
2111                "DELETE FROM pin WHERE source_id = ?1",
2112                vec![SqlValue::Text(source_id.to_owned())],
2113            )?;
2114            conn.cacheflush().context("failed to flush pin database")?;
2115        } else {
2116            fs::remove_file(&db_path)?;
2117        }
2118        Ok(())
2119    }
2120
2121    fn save_store(&self) -> Result<()> {
2122        if self.mirrors_system_winget_sources() {
2123            save_system_winget_mirror_store(&self.app_root, &self.store)
2124        } else {
2125            save_store(&self.app_root, &self.store)
2126        }
2127    }
2128
2129    fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
2130        source_state_dir_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2131    }
2132
2133    fn source_installed_db_path(&self, source: &SourceRecord) -> PathBuf {
2134        source_installed_db_path_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2135    }
2136
2137    fn preindexed_package_path(&self, source: &SourceRecord) -> PathBuf {
2138        self.source_state_dir(source).join("source.msix")
2139    }
2140
2141    fn preindexed_index_path(&self, source: &SourceRecord) -> PathBuf {
2142        self.source_state_dir(source).join("index.db")
2143    }
2144
2145    fn rest_information_cache_path(&self, source: &SourceRecord) -> PathBuf {
2146        self.source_state_dir(source).join("rest-information.json")
2147    }
2148
2149    fn rest_manifest_cache_path(
2150        &self,
2151        source: &SourceRecord,
2152        package_id: &str,
2153        version: &str,
2154        channel: &str,
2155    ) -> PathBuf {
2156        let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
2157        let digest = sha256_hex(key.as_bytes());
2158        cache_root(&self.app_root)
2159            .join("REST_M")
2160            .join(sanitize_path_segment(&source.identifier))
2161            .join(format!("{digest}.json"))
2162    }
2163
2164    // ── Install / download ──
2165
2166    pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
2167        self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
2168    }
2169
2170    pub fn download_installer_for_request(
2171        &mut self,
2172        request: &InstallRequest,
2173        download_dir: &Path,
2174    ) -> Result<DownloadOutput> {
2175        let fetched = self.fetch_installer_for_request(request, download_dir)?;
2176        let manifest_yaml =
2177            build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
2178                .context("failed to render manifest YAML")?;
2179        let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
2180        let manifest_path = download_dir.join(&manifest_filename);
2181        fs::write(&manifest_path, manifest_yaml.as_bytes())
2182            .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
2183
2184        Ok(DownloadOutput {
2185            manifest: fetched.manifest,
2186            installer_path: fetched.installer_path,
2187            manifest_path,
2188        })
2189    }
2190
2191    /// Fetches the installer file without emitting the YAML manifest. Used by
2192    /// the install flow, which downloads into a temp dir and only needs the
2193    /// installer path — emitting a manifest there would litter the temp dir
2194    /// and turn a benign YAML-write failure into an installation failure.
2195    fn fetch_installer_for_request(
2196        &mut self,
2197        request: &InstallRequest,
2198        download_dir: &Path,
2199    ) -> Result<FetchedInstaller> {
2200        let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
2201        let installer = select_installer(&manifest.installers, &request.query)
2202            .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
2203        let url = installer
2204            .url
2205            .as_deref()
2206            .ok_or_else(|| anyhow!("Installer has no URL"))?;
2207
2208        fs::create_dir_all(download_dir)?;
2209        let filename = request.rename.as_deref().unwrap_or_else(|| {
2210            url.rsplit('/')
2211                .next()
2212                .unwrap_or("installer")
2213                .split('?')
2214                .next()
2215                .unwrap_or("installer")
2216        });
2217        let dest = download_dir.join(filename);
2218
2219        self.emit_install_progress(InstallProgress::DownloadingInstaller {
2220            url: url.to_owned(),
2221            path: dest.clone(),
2222        });
2223        let actual_hash = download_installer_to_file(&self.user_agent, url, &dest)?;
2224
2225        self.emit_install_progress(InstallProgress::VerifyingInstaller { path: dest.clone() });
2226        if let Err(error) =
2227            verify_installer_hash_hex(installer.sha256.as_deref(), &actual_hash, request.ignore_security_hash)
2228        {
2229            let _ = fs::remove_file(&dest);
2230            return Err(error);
2231        }
2232
2233        Ok(FetchedInstaller {
2234            manifest,
2235            manifest_documents,
2236            installer,
2237            installer_path: dest,
2238        })
2239    }
2240
2241    pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2242        let mut request = InstallRequest::new(query.clone());
2243        request.mode = if silent {
2244            InstallerMode::Silent
2245        } else {
2246            InstallerMode::SilentWithProgress
2247        };
2248        self.install_request(&request)
2249    }
2250
2251    pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
2252        let mut request = InstallRequest::new(query.clone());
2253        request.mode = mode;
2254        self.install_request(&request)
2255    }
2256
2257    pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
2258        self.emit_install_progress(InstallProgress::ResolvingManifest);
2259        let manifest = self.resolve_manifest_for_install(request)?;
2260        self.emit_install_progress(InstallProgress::ResolvedManifest {
2261            package_id: manifest.id.clone(),
2262            version: manifest.version.clone(),
2263        });
2264        if !package_actions_supported() {
2265            let installer_type = select_installer(&manifest.installers, &request.query)
2266                .and_then(|installer| installer.installer_type)
2267                .unwrap_or_else(|| "install".to_owned())
2268                .to_lowercase();
2269            return Ok(unsupported_action_result(
2270                manifest.id.clone(),
2271                manifest.version.clone(),
2272                installer_type,
2273                INSTALL_UNSUPPORTED_WARNING,
2274            ));
2275        }
2276
2277        self.emit_install_progress(InstallProgress::CheckingInstalled);
2278        let existing_match = self.find_installed_package_for_install(request, &manifest)?;
2279        if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
2280            return Ok(no_op_result);
2281        }
2282
2283        Self::ensure_package_agreements_accepted(&manifest, request)?;
2284        self.emit_install_progress(InstallProgress::InstallingDependencies);
2285        self.install_dependencies(&manifest, request, &mut HashSet::new())?;
2286
2287        if request.dependencies_only {
2288            return Ok(InstallResult {
2289                package_id: manifest.id.clone(),
2290                version: manifest.version.clone(),
2291                installer_path: PathBuf::new(),
2292                installer_type: "dependencies".to_owned(),
2293                exit_code: 0,
2294                success: true,
2295                no_op: false,
2296                warnings: Vec::new(),
2297            });
2298        }
2299
2300        self.emit_install_progress(InstallProgress::SelectingInstaller);
2301        let installer = select_installer(&manifest.installers, &request.query)
2302            .ok_or_else(|| anyhow!("No applicable installer found"))?;
2303
2304        if request.uninstall_previous {
2305            let mut uninstall_request = UninstallRequest::new(PackageQuery {
2306                id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2307                query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
2308                source: request.query.source.clone(),
2309                exact: true,
2310                version: request.query.version.clone(),
2311                ..PackageQuery::default()
2312            });
2313            uninstall_request.product_code = installer.product_code.clone();
2314            uninstall_request.mode = InstallerMode::Silent;
2315            uninstall_request.all_versions = true;
2316            uninstall_request.force = true;
2317            let _ = self.uninstall_request(&uninstall_request);
2318        }
2319
2320        let temp_dir = std::env::temp_dir().join("pinget-install");
2321        let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
2322
2323        let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
2324
2325        self.emit_install_progress(InstallProgress::StartingInstaller {
2326            installer_type: installer_type.clone(),
2327            path: installer_path.clone(),
2328        });
2329        self.emit_install_progress(InstallProgress::WaitingForInstaller {
2330            installer_type: installer_type.clone(),
2331        });
2332        let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
2333        self.emit_install_progress(InstallProgress::InstallerFinished { exit_code });
2334
2335        Ok(InstallResult {
2336            package_id: manifest.id.clone(),
2337            version: manifest.version.clone(),
2338            installer_path,
2339            installer_type,
2340            exit_code,
2341            success: exit_code == 0,
2342            no_op: false,
2343            warnings: Vec::new(),
2344        })
2345    }
2346
2347    fn find_installed_package_for_install(
2348        &mut self,
2349        request: &InstallRequest,
2350        manifest: &Manifest,
2351    ) -> Result<Option<ListMatch>> {
2352        let installed_matches = self.list(&ListQuery {
2353            query: request.query.query.clone(),
2354            id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2355            name: request.query.name.clone(),
2356            moniker: request.query.moniker.clone(),
2357            tag: None,
2358            command: None,
2359            product_code: None,
2360            version: None,
2361            source: request.query.source.clone(),
2362            count: Some(100),
2363            exact: request.query.exact || !manifest.id.trim().is_empty(),
2364            install_scope: request.query.install_scope.clone(),
2365            upgrade_only: false,
2366            include_unknown: false,
2367            include_pinned: false,
2368        })?;
2369
2370        Ok(installed_matches.matches.into_iter().find(|candidate| {
2371            candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
2372        }))
2373    }
2374
2375    fn create_install_no_op_result(
2376        request: &InstallRequest,
2377        manifest: &Manifest,
2378        existing_match: Option<&ListMatch>,
2379    ) -> Option<InstallResult> {
2380        let existing_match = existing_match?;
2381
2382        if request.no_upgrade {
2383            return Some(InstallResult {
2384                package_id: manifest.id.clone(),
2385                version: existing_match.installed_version.clone(),
2386                installer_path: PathBuf::new(),
2387                installer_type: "install".to_owned(),
2388                exit_code: 0,
2389                success: true,
2390                no_op: true,
2391                warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
2392            });
2393        }
2394
2395        if !request.force
2396            && !existing_match.installed_version.trim().is_empty()
2397            && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
2398        {
2399            return Some(InstallResult {
2400                package_id: manifest.id.clone(),
2401                version: existing_match.installed_version.clone(),
2402                installer_path: PathBuf::new(),
2403                installer_type: "install".to_owned(),
2404                exit_code: 0,
2405                success: true,
2406                no_op: true,
2407                warnings: vec![
2408                    "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
2409                ],
2410            });
2411        }
2412
2413        None
2414    }
2415
2416    pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
2417        if request.manifest_path.is_none() && !package_actions_supported() {
2418            let package_id = request
2419                .query
2420                .id
2421                .clone()
2422                .or_else(|| request.query.name.clone())
2423                .or_else(|| request.query.moniker.clone())
2424                .or_else(|| request.query.query.clone())
2425                .or_else(|| request.product_code.clone())
2426                .unwrap_or_else(|| "repair".to_owned());
2427            return Ok(unsupported_action_result(
2428                package_id,
2429                request.query.version.clone().unwrap_or_default(),
2430                "repair".to_owned(),
2431                REPAIR_UNSUPPORTED_WARNING,
2432            ));
2433        }
2434
2435        let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2436        let installed_match = if request.manifest_path.is_some() {
2437            None
2438        } else {
2439            let installed = self.list(&Self::create_repair_list_query(request))?;
2440            warnings.extend(installed.warnings);
2441            if installed.matches.is_empty() {
2442                bail!("No installed package matched the supplied repair query.");
2443            }
2444            if installed.matches.len() > 1 {
2445                bail!("Multiple installed packages matched the supplied repair query.");
2446            }
2447            installed.matches.into_iter().next()
2448        };
2449
2450        let mut result =
2451            self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2452        warnings.extend(result.warnings);
2453        result.warnings = warnings;
2454        Ok(result)
2455    }
2456
2457    pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2458        let mut request = UninstallRequest::new(query.clone());
2459        request.mode = if silent {
2460            InstallerMode::Silent
2461        } else {
2462            InstallerMode::SilentWithProgress
2463        };
2464        self.uninstall_request(&request)
2465    }
2466
2467    pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2468        if !package_actions_supported() {
2469            let (package_id, version) = if let Some(path) = &request.manifest_path {
2470                let manifest = Self::load_manifest_from_path(path)?;
2471                (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2472            } else {
2473                (
2474                    request
2475                        .query
2476                        .id
2477                        .clone()
2478                        .or_else(|| request.query.query.clone())
2479                        .or_else(|| request.query.name.clone())
2480                        .or_else(|| request.query.moniker.clone())
2481                        .or_else(|| request.product_code.clone())
2482                        .unwrap_or_else(|| "unknown".to_owned()),
2483                    request.query.version.clone().unwrap_or_default(),
2484                )
2485            };
2486
2487            return Ok(unsupported_action_result(
2488                package_id,
2489                version,
2490                "uninstall",
2491                UNINSTALL_UNSUPPORTED_WARNING,
2492            ));
2493        }
2494
2495        let matches = self.resolve_uninstall_matches(request)?;
2496        let mut exit_code = 0;
2497
2498        for installed in &matches {
2499            exit_code = uninstall_package(installed, request)?;
2500            if exit_code != 0 && !request.all_versions {
2501                break;
2502            }
2503        }
2504
2505        let installed = matches
2506            .first()
2507            .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2508
2509        Ok(InstallResult {
2510            package_id: installed.id.clone(),
2511            version: installed.installed_version.clone(),
2512            installer_path: PathBuf::new(),
2513            installer_type: "uninstall".to_owned(),
2514            exit_code,
2515            success: exit_code == 0,
2516            no_op: false,
2517            warnings: Vec::new(),
2518        })
2519    }
2520
2521    fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2522        self.resolve_manifest_and_documents_for_install(request)
2523            .map(|(manifest, _)| manifest)
2524    }
2525
2526    fn resolve_manifest_and_documents_for_install(
2527        &mut self,
2528        request: &InstallRequest,
2529    ) -> Result<(Manifest, JsonValue)> {
2530        if let Some(path) = &request.manifest_path {
2531            return Self::load_manifest_from_path_with_documents(path);
2532        }
2533
2534        let (located, _warnings) = self.find_single_match(&request.query)?;
2535        let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2536        Ok((manifest, documents))
2537    }
2538
2539    fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2540        if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2541            bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2542        }
2543        Ok(())
2544    }
2545
2546    fn install_dependencies(
2547        &mut self,
2548        manifest: &Manifest,
2549        request: &InstallRequest,
2550        visited: &mut HashSet<String>,
2551    ) -> Result<()> {
2552        if request.skip_dependencies {
2553            return Ok(());
2554        }
2555
2556        let dependency_ids = manifest
2557            .package_dependencies
2558            .iter()
2559            .chain(
2560                manifest
2561                    .installers
2562                    .iter()
2563                    .flat_map(|installer| installer.package_dependencies.iter()),
2564            )
2565            .filter(|value| !value.trim().is_empty())
2566            .cloned()
2567            .collect::<BTreeSet<_>>();
2568
2569        for dependency_id in dependency_ids {
2570            if !visited.insert(dependency_id.clone()) {
2571                continue;
2572            }
2573
2574            let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2575            self.install_request(&dependency_request)?;
2576        }
2577
2578        Ok(())
2579    }
2580
2581    fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2582        let mut dependency_request = InstallRequest::new(PackageQuery {
2583            id: Some(dependency_id),
2584            source: request
2585                .dependency_source
2586                .clone()
2587                .or_else(|| request.query.source.clone()),
2588            exact: true,
2589            ..PackageQuery::default()
2590        });
2591        dependency_request.mode = match request.mode {
2592            InstallerMode::Interactive => InstallerMode::Interactive,
2593            InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2594        };
2595        dependency_request.accept_package_agreements = request.accept_package_agreements;
2596        dependency_request.no_upgrade = true;
2597        dependency_request.ignore_security_hash = request.ignore_security_hash;
2598        dependency_request.dependency_source = request.dependency_source.clone();
2599        dependency_request
2600    }
2601
2602    fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2603        ListQuery {
2604            query: request.query.query.clone(),
2605            id: request.query.id.clone(),
2606            name: request.query.name.clone(),
2607            moniker: request.query.moniker.clone(),
2608            product_code: request.product_code.clone(),
2609            version: request.query.version.clone(),
2610            source: request.query.source.clone(),
2611            count: Some(100),
2612            exact: request.query.exact,
2613            install_scope: request.query.install_scope.clone(),
2614            ..ListQuery::default()
2615        }
2616    }
2617
2618    fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2619        let mut query = request.query.clone();
2620        query.query = if installed_match.is_none() {
2621            request.query.query.clone()
2622        } else {
2623            None
2624        };
2625        query.id = installed_match
2626            .map(|item| item.id.clone())
2627            .or_else(|| request.query.id.clone());
2628        query.name = if installed_match.is_none() {
2629            request.query.name.clone()
2630        } else {
2631            None
2632        };
2633        query.moniker = if installed_match.is_none() {
2634            request.query.moniker.clone()
2635        } else {
2636            None
2637        };
2638        query.source = request
2639            .query
2640            .source
2641            .clone()
2642            .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2643        query.exact = true;
2644        query.version = request
2645            .query
2646            .version
2647            .clone()
2648            .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2649
2650        InstallRequest {
2651            query,
2652            manifest_path: request.manifest_path.clone(),
2653            mode: request.mode,
2654            log_path: request.log_path.clone(),
2655            custom: None,
2656            override_args: None,
2657            install_location: None,
2658            skip_dependencies: false,
2659            dependencies_only: false,
2660            accept_package_agreements: request.accept_package_agreements,
2661            force: true,
2662            rename: None,
2663            uninstall_previous: false,
2664            ignore_security_hash: request.ignore_security_hash,
2665            dependency_source: None,
2666            no_upgrade: false,
2667        }
2668    }
2669
2670    fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2671        let mut effective_query = request.query.clone();
2672        let mut effective_product_code = request.product_code.clone();
2673
2674        if let Some(path) = &request.manifest_path {
2675            let manifest = Self::load_manifest_from_path(path)?;
2676            effective_query = PackageQuery {
2677                query: Some(manifest.id.clone()),
2678                id: Some(manifest.id.clone()),
2679                name: Some(manifest.name.clone()),
2680                exact: true,
2681                version: effective_query.version.clone().or(Some(manifest.version)),
2682                ..PackageQuery::default()
2683            };
2684            effective_product_code = effective_product_code.or_else(|| {
2685                manifest
2686                    .installers
2687                    .iter()
2688                    .find_map(|installer| installer.product_code.clone())
2689            });
2690        }
2691
2692        let list_query = ListQuery {
2693            query: effective_query.query.clone(),
2694            id: effective_query.id.clone(),
2695            name: effective_query.name.clone(),
2696            moniker: effective_query.moniker.clone(),
2697            tag: None,
2698            command: None,
2699            product_code: effective_product_code,
2700            version: effective_query.version.clone(),
2701            source: effective_query.source.clone(),
2702            count: if request.all_versions { None } else { Some(100) },
2703            exact: effective_query.exact,
2704            install_scope: effective_query.install_scope,
2705            upgrade_only: false,
2706            include_unknown: false,
2707            include_pinned: false,
2708        };
2709
2710        let list_result = self.list(&list_query)?;
2711        if list_result.matches.is_empty() {
2712            bail!("No installed package found matching the query");
2713        }
2714        if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2715            bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2716        }
2717
2718        Ok(if request.all_versions {
2719            list_result.matches
2720        } else {
2721            vec![list_result.matches[0].clone()]
2722        })
2723    }
2724
2725    fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2726        Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2727    }
2728
2729    fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2730        let resolved = resolve_manifest_path(manifest_path)?;
2731        let bytes =
2732            fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2733        parse_yaml_manifest_bundle(&bytes)
2734    }
2735
2736    fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2737        self.find_single_match_with_semantics(query, SearchSemantics::Single)
2738    }
2739
2740    fn find_single_match_for_show(
2741        &mut self,
2742        query: &PackageQuery,
2743        semantics: SearchSemantics,
2744    ) -> Result<(LocatedMatch, Vec<String>)> {
2745        let located = self.search_located(query, semantics)?;
2746        if self.should_retry_show_across_system_winget_sources(query, &located) {
2747            let mut fallback_query = query.clone();
2748            fallback_query.source = None;
2749            return single_match_from_located(self.search_located(&fallback_query, semantics)?, false);
2750        }
2751
2752        single_match_from_located(located, query.source.is_some())
2753    }
2754
2755    fn should_retry_show_across_system_winget_sources(
2756        &self,
2757        query: &PackageQuery,
2758        located: &SearchLocatedResult,
2759    ) -> bool {
2760        should_retry_show_across_system_winget_sources(self.uses_system_winget_sources(), query, located)
2761    }
2762
2763    fn find_single_match_with_semantics(
2764        &mut self,
2765        query: &PackageQuery,
2766        semantics: SearchSemantics,
2767    ) -> Result<(LocatedMatch, Vec<String>)> {
2768        let located = self.search_located(query, semantics)?;
2769        single_match_from_located(located, query.source.is_some())
2770    }
2771
2772    fn search_located(&mut self, query: &PackageQuery, semantics: SearchSemantics) -> Result<SearchLocatedResult> {
2773        let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2774        let mut matches = Vec::new();
2775        let mut warnings = Vec::new();
2776        let mut failures = Vec::new();
2777        let mut truncated = false;
2778
2779        for index in indexes {
2780            match self.search_source(index, query, semantics) {
2781                Ok(mut source_matches) => {
2782                    truncated |= source_matches.truncated;
2783                    matches.append(&mut source_matches.matches);
2784                }
2785                Err(error) => {
2786                    let source_name = self.store.sources[index].name.clone();
2787                    let message = format!("{error:#}");
2788                    warnings.push(format!(
2789                        "Failed when searching source '{}'; results will not be included: {}",
2790                        source_name, message
2791                    ));
2792                    failures.push(SourceSearchFailure { source_name, message });
2793                }
2794            }
2795        }
2796
2797        if semantics == SearchSemantics::Many {
2798            matches.sort_by(|left, right| {
2799                search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2800            });
2801            let limit = max_results(query);
2802            if matches.len() > limit {
2803                truncated = true;
2804                matches.truncate(limit);
2805            }
2806        }
2807
2808        Ok(SearchLocatedResult {
2809            matches,
2810            warnings,
2811            failures,
2812            truncated,
2813        })
2814    }
2815
2816    fn search_source(
2817        &mut self,
2818        source_index: usize,
2819        query: &PackageQuery,
2820        semantics: SearchSemantics,
2821    ) -> Result<SearchSourceMatches> {
2822        match self.store.sources[source_index].kind {
2823            SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2824            SourceKind::Rest => self.search_rest(source_index, query, semantics),
2825        }
2826    }
2827
2828    fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2829        match &located.locator {
2830            MatchLocator::PreIndexedV1 { package_rowid } => {
2831                let source = self.source_clone(located.source_index);
2832                let connection = self.open_preindexed_connection(located.source_index)?;
2833                let versions = query_v1_versions(&connection, *package_rowid)?;
2834                let mut keys = versions
2835                    .into_iter()
2836                    .map(|row| VersionKey {
2837                        version: row.version,
2838                        channel: row.channel,
2839                    })
2840                    .collect::<Vec<_>>();
2841                sort_versions_desc(&mut keys);
2842                if keys.is_empty() {
2843                    bail!("no versions found for {} in {}", located.display.id, source.name);
2844                }
2845                Ok(keys)
2846            }
2847            MatchLocator::PreIndexedV2 {
2848                package_rowid,
2849                package_hash,
2850            } => {
2851                let source = self.source_clone(located.source_index);
2852                let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2853                let mut keys = entries
2854                    .into_iter()
2855                    .map(|entry| VersionKey {
2856                        version: entry.version,
2857                        channel: String::new(),
2858                    })
2859                    .collect::<Vec<_>>();
2860                sort_versions_desc(&mut keys);
2861                Ok(keys)
2862            }
2863            MatchLocator::Rest { versions, .. } => {
2864                let mut keys = versions.clone();
2865                sort_versions_desc(&mut keys);
2866                Ok(keys)
2867            }
2868        }
2869    }
2870
2871    fn manifest_for_match(
2872        &mut self,
2873        located: &LocatedMatch,
2874        query: &PackageQuery,
2875    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2876        self.manifest_for_match_with_refresh(located, query, true)
2877    }
2878
2879    fn manifest_for_match_with_refresh(
2880        &mut self,
2881        located: &LocatedMatch,
2882        query: &PackageQuery,
2883        allow_explicit_version_refresh: bool,
2884    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2885        match &located.locator {
2886            MatchLocator::PreIndexedV1 { package_rowid } => {
2887                let source = self.source_clone(located.source_index);
2888                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2889                let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2890                let selected = match select_v1_version(
2891                    &versions,
2892                    query.version.as_deref(),
2893                    query.channel.as_deref(),
2894                    &located.display.id,
2895                    &source.name,
2896                ) {
2897                    Ok(selected) => selected,
2898                    Err(error)
2899                        if allow_explicit_version_refresh
2900                            && query.version.is_some()
2901                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2902                    {
2903                        let open_refresh = opened.refresh.clone();
2904                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2905                            return missing_error_after_open_refresh(error, &open_refresh);
2906                        }
2907                        if self.has_recent_successful_preindexed_refresh_attempt(
2908                            &self.preindexed_refresh_lock_key(&source),
2909                        ) {
2910                            return Err(error);
2911                        }
2912                        drop(opened);
2913                        return self.refresh_preindexed_and_retry_manifest(
2914                            located.source_index,
2915                            &located.display,
2916                            query,
2917                            error,
2918                        );
2919                    }
2920                    Err(error) => return Err(error),
2921                };
2922                let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2923                let bytes =
2924                    self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2925                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2926                manifest.version = selected.version.clone();
2927                manifest.channel = selected.channel;
2928                Ok((manifest, manifest_documents, vec![bytes.path]))
2929            }
2930            MatchLocator::PreIndexedV2 {
2931                package_rowid,
2932                package_hash,
2933            } => {
2934                let source = self.source_clone(located.source_index);
2935                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2936                let (entries, version_data_file) =
2937                    self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2938                let selected = match select_v2_version(
2939                    &entries,
2940                    query.version.as_deref(),
2941                    query.channel.as_deref(),
2942                    &located.display.id,
2943                    &source.name,
2944                ) {
2945                    Ok(selected) => selected,
2946                    Err(error)
2947                        if allow_explicit_version_refresh
2948                            && query.version.is_some()
2949                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2950                    {
2951                        let open_refresh = opened.refresh.clone();
2952                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2953                            return missing_error_after_open_refresh(error, &open_refresh);
2954                        }
2955                        if self.has_recent_successful_preindexed_refresh_attempt(
2956                            &self.preindexed_refresh_lock_key(&source),
2957                        ) {
2958                            return Err(error);
2959                        }
2960                        drop(opened);
2961                        return self.refresh_preindexed_and_retry_manifest(
2962                            located.source_index,
2963                            &located.display,
2964                            query,
2965                            error,
2966                        );
2967                    }
2968                    Err(error) => return Err(error),
2969                };
2970                let manifest_bytes = self.get_cached_source_file(
2971                    "V2_M",
2972                    &source,
2973                    &selected.manifest_relative_path,
2974                    Some(selected.manifest_hash.as_str()),
2975                )?;
2976                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2977                manifest.version = selected.version;
2978                Ok((
2979                    manifest,
2980                    manifest_documents,
2981                    vec![version_data_file, manifest_bytes.path],
2982                ))
2983            }
2984            MatchLocator::Rest { package_id, versions } => {
2985                let source = self.source_clone(located.source_index);
2986                let selected = select_rest_version(
2987                    versions,
2988                    query.version.as_deref(),
2989                    query.channel.as_deref(),
2990                    &located.display.id,
2991                    &source.name,
2992                )?;
2993                let (bytes, cache_path) =
2994                    self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2995                let (manifest, manifest_documents) =
2996                    parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2997                Ok((manifest, manifest_documents, vec![cache_path]))
2998            }
2999        }
3000    }
3001
3002    fn refresh_preindexed_and_retry_manifest(
3003        &mut self,
3004        source_index: usize,
3005        previous_display: &SearchMatch,
3006        query: &PackageQuery,
3007        missing_error: anyhow::Error,
3008    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
3009        self.refresh_preindexed_source(source_index, true)
3010            .with_context(|| format!("{missing_error} The source refresh also failed"))?;
3011        let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
3012        self.manifest_for_match_with_refresh(&refreshed, query, false)
3013    }
3014
3015    fn find_refreshed_preindexed_match(
3016        &mut self,
3017        source_index: usize,
3018        previous_display: &SearchMatch,
3019        query: &PackageQuery,
3020    ) -> Result<LocatedMatch> {
3021        let source_name = self.store.sources[source_index].name.clone();
3022        let mut source_query = query.clone();
3023        source_query.source = Some(source_name.clone());
3024        let matches = self
3025            .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
3026            .matches;
3027        if let Some(found) = matches
3028            .iter()
3029            .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
3030        {
3031            return Ok(found.clone());
3032        }
3033        if matches.len() == 1 {
3034            return Ok(matches.into_iter().next().expect("single match"));
3035        }
3036        bail!(
3037            "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
3038            previous_display.id,
3039            source_name
3040        )
3041    }
3042
3043    fn search_preindexed(
3044        &mut self,
3045        source_index: usize,
3046        query: &PackageQuery,
3047        semantics: SearchSemantics,
3048    ) -> Result<SearchSourceMatches> {
3049        let connection = self.open_preindexed_connection(source_index)?;
3050        let source = self.source_clone(source_index);
3051
3052        match query_v2_matches(&connection, query, semantics) {
3053            Ok((rows, truncated)) => Ok(SearchSourceMatches {
3054                truncated,
3055                matches: rows
3056                    .into_iter()
3057                    .map(|row| LocatedMatch {
3058                        display: SearchMatch {
3059                            source_name: source.name.clone(),
3060                            source_kind: source.kind,
3061                            id: row.id.clone(),
3062                            name: row.name.clone(),
3063                            moniker: row.moniker.clone(),
3064                            version: Some(row.version.clone()),
3065                            channel: None,
3066                            match_criteria: row.match_criteria.clone(),
3067                        },
3068                        source_index,
3069                        locator: MatchLocator::PreIndexedV2 {
3070                            package_rowid: row.package_rowid,
3071                            package_hash: row.package_hash,
3072                        },
3073                    })
3074                    .collect(),
3075            }),
3076            Err(error) if can_fallback_to_v1(&error) => {
3077                let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
3078                let grouped = group_v1_rows(rows);
3079
3080                Ok(SearchSourceMatches {
3081                    truncated,
3082                    matches: grouped
3083                        .into_iter()
3084                        .map(|row| LocatedMatch {
3085                            display: SearchMatch {
3086                                source_name: source.name.clone(),
3087                                source_kind: source.kind,
3088                                id: row.id.clone(),
3089                                name: row.name.clone(),
3090                                moniker: row.moniker.clone(),
3091                                version: Some(row.version.clone()),
3092                                channel: Some(row.channel.clone()),
3093                                match_criteria: row.match_criteria.clone(),
3094                            },
3095                            source_index,
3096                            locator: MatchLocator::PreIndexedV1 {
3097                                package_rowid: row.package_rowid,
3098                            },
3099                        })
3100                        .collect(),
3101                })
3102            }
3103            Err(error) => Err(error),
3104        }
3105    }
3106
3107    fn search_rest(
3108        &mut self,
3109        source_index: usize,
3110        query: &PackageQuery,
3111        semantics: SearchSemantics,
3112    ) -> Result<SearchSourceMatches> {
3113        let source = self.source_clone(source_index);
3114        let info = self.load_rest_information(source_index)?;
3115        let contract = choose_contract(&info.server_supported_versions)
3116            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3117
3118        let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
3119        let body = build_rest_search_body(query, &info, semantics)?;
3120        let response = self
3121            .client
3122            .post(url)
3123            .header("Version", contract)
3124            .json(&body)
3125            .send()
3126            .context("REST search request failed")?
3127            .error_for_status()
3128            .context("REST search request returned an error")?;
3129        let json = response
3130            .json::<JsonValue>()
3131            .context("failed to parse REST search response")?;
3132        let data = json
3133            .get("Data")
3134            .and_then(JsonValue::as_array)
3135            .cloned()
3136            .unwrap_or_default();
3137        let max_results = source_fetch_results(query, semantics);
3138
3139        let mut results = Vec::new();
3140        for item in data {
3141            let package_id = json_string(&item, "PackageIdentifier")
3142                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
3143            let package_name =
3144                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
3145            let mut versions = parse_rest_versions(&item)?;
3146            sort_versions_desc(&mut versions);
3147            let latest = versions
3148                .first()
3149                .cloned()
3150                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
3151
3152            results.push(LocatedMatch {
3153                display: SearchMatch {
3154                    source_name: source.name.clone(),
3155                    source_kind: source.kind,
3156                    id: package_id.clone(),
3157                    name: package_name,
3158                    moniker: json_string(&item, "Moniker"),
3159                    version: Some(latest.version.clone()),
3160                    channel: if latest.channel.is_empty() {
3161                        None
3162                    } else {
3163                        Some(latest.channel.clone())
3164                    },
3165                    match_criteria: rest_match_criteria(&item, query, semantics),
3166                },
3167                source_index,
3168                locator: MatchLocator::Rest { package_id, versions },
3169            });
3170        }
3171
3172        Ok(SearchSourceMatches {
3173            truncated: results.len() >= max_results,
3174            matches: results,
3175        })
3176    }
3177
3178    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
3179        Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
3180    }
3181
3182    fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
3183        let source = self.source_clone(source_index);
3184        let index_path = self.preindexed_index_path(&source);
3185        let mut refresh = PreindexedRefreshOutcome::default();
3186        if !index_path.exists() {
3187            let _ = self.refresh_preindexed_source(source_index, true)?;
3188            refresh.attempted = true;
3189        } else if self.is_preindexed_index_stale(&source, &index_path) {
3190            let lock_key = self.preindexed_refresh_lock_key(&source);
3191            if self.has_recent_preindexed_refresh_attempt(&lock_key) {
3192                refresh.attempted = true;
3193                refresh.reused_recent_attempt = true;
3194            } else {
3195                refresh.attempted = true;
3196                if let Err(error) = self.refresh_preindexed_source(source_index, false) {
3197                    refresh.error = Some(format!("{error:#}"));
3198                }
3199            }
3200        }
3201
3202        Ok(PreindexedOpenConnection {
3203            connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
3204            refresh,
3205        })
3206    }
3207
3208    fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
3209        let Some(interval) = self.pre_indexed_source_auto_update_interval else {
3210            return false;
3211        };
3212
3213        let index_modified = || {
3214            fs::metadata(index_path)
3215                .ok()
3216                .and_then(|metadata| metadata.modified().ok())
3217                .map(DateTime::<Utc>::from)
3218        };
3219
3220        let last_update = match (source.last_update, index_modified()) {
3221            (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
3222            (Some(source_last_update), None) => Some(source_last_update),
3223            (None, Some(index_last_write)) => Some(index_last_write),
3224            (None, None) => None,
3225        };
3226
3227        match last_update {
3228            Some(last_update) => Utc::now() - last_update >= interval,
3229            None => true,
3230        }
3231    }
3232
3233    fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
3234        format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
3235    }
3236
3237    fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3238        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3239            Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3240        })
3241    }
3242
3243    fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3244        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3245            attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3246        })
3247    }
3248
3249    fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
3250        self.preindexed_refresh_attempts.insert(
3251            lock_key,
3252            PreindexedRefreshAttempt {
3253                attempted_at: Utc::now(),
3254                succeeded,
3255            },
3256        );
3257    }
3258
3259    fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
3260        {
3261            let locks = preindexed_refresh_locks()
3262                .read()
3263                .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3264            if let Some(existing) = locks.get(lock_key) {
3265                return Ok(Arc::clone(existing));
3266            }
3267        }
3268
3269        let mut locks = preindexed_refresh_locks()
3270            .write()
3271            .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3272        Ok(Arc::clone(
3273            locks
3274                .entry(lock_key.to_owned())
3275                .or_insert_with(|| Arc::new(Mutex::new(()))),
3276        ))
3277    }
3278
3279    fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
3280        let source = self.source_clone(source_index);
3281        let index_path = self.preindexed_index_path(&source);
3282        let lock_key = self.preindexed_refresh_lock_key(&source);
3283        let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
3284        let _guard = refresh_lock
3285            .lock()
3286            .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
3287
3288        if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
3289            return Ok("source already refreshed".to_owned());
3290        }
3291
3292        let result = self.update_preindexed(source_index);
3293        self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
3294        let detail = result?;
3295        self.save_store()?;
3296        Ok(detail)
3297    }
3298
3299    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
3300        let path_text = path.to_string_lossy().into_owned();
3301        let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
3302            .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
3303        database
3304            .connect()
3305            .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
3306    }
3307
3308    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
3309        let source_snapshot = self.store.sources[source_index].clone();
3310        let state_dir = self.source_state_dir(&source_snapshot);
3311        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
3312        let index_path = self.preindexed_index_path(&source_snapshot);
3313        let has_local_index = index_path.exists();
3314
3315        let mut last_error = None;
3316        for candidate in PREINDEXED_CANDIDATES {
3317            let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate);
3318            if has_local_index
3319                && let Some(detail) =
3320                    self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate)
3321            {
3322                return Ok(detail);
3323            }
3324
3325            let mut request = self.client.get(&url);
3326            if has_local_index {
3327                if let Some(etag) = &source_snapshot.etag
3328                    && let Ok(value) = HeaderValue::from_str(etag)
3329                {
3330                    request = request.header(IF_NONE_MATCH, value);
3331                }
3332                if let Some(last_modified) = &source_snapshot.last_modified
3333                    && let Ok(value) = HeaderValue::from_str(last_modified)
3334                {
3335                    request = request.header(IF_MODIFIED_SINCE, value);
3336                }
3337            }
3338
3339            match request.send() {
3340                Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => {
3341                    let source = &mut self.store.sources[source_index];
3342                    update_preindexed_http_validators(source, response.headers());
3343                    source.last_update = Some(Utc::now());
3344                    return Ok(format!("already up to date from {}", candidate));
3345                }
3346                Ok(response) if response.status().is_success() => {
3347                    let header_version = response
3348                        .headers()
3349                        .get("x-ms-meta-sourceversion")
3350                        .and_then(|value| value.to_str().ok())
3351                        .map(str::to_owned);
3352                    let headers = response.headers().clone();
3353                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
3354                    let payload = bytes.to_vec();
3355                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
3356                        .context("preindexed package did not contain Public/index.db")?;
3357
3358                    fs::write(self.preindexed_package_path(&source_snapshot), &payload)
3359                        .context("failed to persist source package")?;
3360                    let temp_index_path = state_dir.join(format!(
3361                        "index.{}.tmp",
3362                        Utc::now().timestamp_nanos_opt().unwrap_or_default()
3363                    ));
3364                    fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
3365                    if let Err(error) = replace_file(&temp_index_path, &index_path) {
3366                        let _ = fs::remove_file(&temp_index_path);
3367                        return Err(error).context("failed to persist source index");
3368                    }
3369                    // The new index.db is a fresh database; any WAL/SHM sidecars
3370                    // left from the previous one no longer match it and would make
3371                    // SQLite recover against the wrong database. Drop them so the
3372                    // next open starts clean.
3373                    remove_sqlite_sidecars(&index_path);
3374                    let source = &mut self.store.sources[source_index];
3375                    source.last_update = Some(Utc::now());
3376                    source.source_version = header_version;
3377                    update_preindexed_http_validators(source, &headers);
3378                    return Ok(format!("downloaded {}", candidate));
3379                }
3380                Ok(response) => {
3381                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3382                }
3383                Err(error) => {
3384                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3385                }
3386            }
3387        }
3388
3389        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3390    }
3391
3392    fn try_skip_unchanged_preindexed_by_source_version(
3393        &mut self,
3394        source_index: usize,
3395        url: &str,
3396        candidate: &str,
3397    ) -> Option<String> {
3398        let current_version = self.store.sources[source_index].source_version.as_deref()?;
3399        let response = self.client.head(url).send().ok()?;
3400        if !response.status().is_success() {
3401            return None;
3402        }
3403
3404        let header_version = response
3405            .headers()
3406            .get("x-ms-meta-sourceversion")
3407            .and_then(|value| value.to_str().ok())?;
3408        if !header_version.eq_ignore_ascii_case(current_version) {
3409            return None;
3410        }
3411
3412        let source = &mut self.store.sources[source_index];
3413        update_preindexed_http_validators(source, response.headers());
3414        source.last_update = Some(Utc::now());
3415        Some(format!("already up to date from {} (v{})", candidate, header_version))
3416    }
3417
3418    fn update_rest(&mut self, source_index: usize) -> Result<String> {
3419        let info = self.fetch_rest_information(source_index, true)?;
3420        let source = &mut self.store.sources[source_index];
3421        if !info.source_identifier.is_empty() {
3422            source.identifier = info.source_identifier.clone();
3423        }
3424        source.last_update = Some(Utc::now());
3425        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3426        Ok("refreshed information cache".to_owned())
3427    }
3428
3429    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3430        let source = self.source_clone(source_index);
3431        let cache_path = self.rest_information_cache_path(&source);
3432
3433        if cache_path.exists() {
3434            let cache = serde_json::from_slice::<RestInfoCache>(
3435                &fs::read(&cache_path).context("failed to read REST information cache")?,
3436            )
3437            .context("failed to parse REST information cache")?;
3438            if cache.expires_at > Utc::now() {
3439                return Ok(cache.value);
3440            }
3441        }
3442
3443        self.fetch_rest_information(source_index, true)
3444    }
3445
3446    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3447        let source = self.source_clone(source_index);
3448        let url = format!("{}/information", source.arg.trim_end_matches('/'));
3449        let response = self
3450            .client
3451            .get(url)
3452            .send()
3453            .context("REST information request failed")?
3454            .error_for_status()
3455            .context("REST information request returned an error")?;
3456
3457        let max_age = cache_control_max_age(&response);
3458        let json = response
3459            .json::<JsonValue>()
3460            .context("failed to parse REST information response")?;
3461        let data = json
3462            .get("Data")
3463            .cloned()
3464            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3465        let info = serde_json::from_value::<RestInformation>(data)
3466            .context("failed to deserialize REST information payload")?;
3467
3468        if persist {
3469            let cache = RestInfoCache {
3470                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3471                value: info.clone(),
3472            };
3473            write_json(self.rest_information_cache_path(&source), &cache)?;
3474        }
3475
3476        Ok(info)
3477    }
3478
3479    fn load_v2_version_data(
3480        &self,
3481        source: &SourceRecord,
3482        package_rowid: i64,
3483        package_hash: &str,
3484    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3485        let connection = Self::open_sqlite_connection(self.preindexed_index_path(source))
3486            .context("failed to reopen preindexed index for V2 version data")?;
3487        let package_hash = package_hash.to_ascii_lowercase();
3488        let package_id = query_optional_value(
3489            &connection,
3490            "SELECT id FROM packages WHERE rowid = ?1",
3491            vec![SqlValue::Integer(package_rowid)],
3492            |row| row_string(row, 0),
3493        )?
3494        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3495        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3496        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3497        let yaml = decompress_mszyml(&file.bytes)?;
3498        let document =
3499            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3500        Ok((document.versions, file.path))
3501    }
3502
3503    fn get_cached_source_file(
3504        &self,
3505        bucket: &str,
3506        source: &SourceRecord,
3507        relative_path: &str,
3508        expected_hash: Option<&str>,
3509    ) -> Result<CachedBytes> {
3510        let normalized_relative = relative_path.replace('\\', "/");
3511        let cache_path =
3512            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3513
3514        if cache_path.exists() {
3515            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3516            if hash_matches(expected_hash, &cached) {
3517                return Ok(CachedBytes {
3518                    path: cache_path,
3519                    bytes: cached,
3520                });
3521            }
3522        }
3523
3524        let url = format!(
3525            "{}/{}",
3526            source.arg.trim_end_matches('/'),
3527            normalized_relative.trim_start_matches('/')
3528        );
3529        let response = self
3530            .client
3531            .get(url)
3532            .send()
3533            .context("failed to fetch source file")?
3534            .error_for_status()
3535            .context("source file request returned an error")?;
3536        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3537
3538        if let Some(hash) = expected_hash {
3539            verify_hash(hash, &bytes)?;
3540        }
3541
3542        if let Some(parent) = cache_path.parent() {
3543            fs::create_dir_all(parent).context("failed to create cache directory")?;
3544        }
3545        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3546
3547        Ok(CachedBytes {
3548            path: cache_path,
3549            bytes,
3550        })
3551    }
3552
3553    fn get_or_fetch_rest_manifest(
3554        &mut self,
3555        source: &SourceRecord,
3556        package_id: &str,
3557        version: &str,
3558        channel: &str,
3559    ) -> Result<(Vec<u8>, PathBuf)> {
3560        let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel);
3561        if cache_path.exists() {
3562            return Ok((
3563                fs::read(&cache_path).context("failed to read cached REST manifest")?,
3564                cache_path,
3565            ));
3566        }
3567
3568        let info = self.load_rest_information_by_name(&source.name)?;
3569        let contract = choose_contract(&info.server_supported_versions)
3570            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3571        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3572
3573        let mut params = vec![("Version", version.to_owned())];
3574        if !channel.is_empty() {
3575            params.push(("Channel", channel.to_owned()));
3576        }
3577        if info
3578            .required_query_parameters
3579            .iter()
3580            .any(|value| value.eq_ignore_ascii_case("Market"))
3581        {
3582            params.push(("Market", default_market()));
3583        }
3584
3585        let response = self
3586            .client
3587            .get(url)
3588            .header("Version", contract)
3589            .query(&params)
3590            .send()
3591            .context("REST manifest request failed")?
3592            .error_for_status()
3593            .context("REST manifest request returned an error")?;
3594        let bytes = response
3595            .bytes()
3596            .context("failed to read REST manifest response")?
3597            .to_vec();
3598
3599        if let Some(parent) = cache_path.parent() {
3600            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3601        }
3602        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3603
3604        Ok((bytes, cache_path))
3605    }
3606
3607    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3608        let index = self
3609            .store
3610            .sources
3611            .iter()
3612            .position(|source| source.name.eq_ignore_ascii_case(name))
3613            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3614        self.load_rest_information(index)
3615    }
3616
3617    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3618        if let Some(name) = source_name {
3619            if let Some(index) = self
3620                .store
3621                .sources
3622                .iter()
3623                .position(|source| source.name.eq_ignore_ascii_case(name))
3624            {
3625                return Ok(vec![index]);
3626            }
3627
3628            let index = self
3629                .store
3630                .sources
3631                .iter()
3632                .position(|source| source.identifier.eq_ignore_ascii_case(name))
3633                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3634            return Ok(vec![index]);
3635        }
3636
3637        let mut indexes: Vec<_> = self
3638            .store
3639            .sources
3640            .iter()
3641            .enumerate()
3642            .filter(|(_, source)| !source.explicit)
3643            .collect();
3644        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3645            right_source
3646                .priority
3647                .cmp(&left_source.priority)
3648                .then_with(|| left_index.cmp(right_index))
3649        });
3650        Ok(indexes.into_iter().map(|(index, _)| index).collect())
3651    }
3652
3653    fn source_clone(&self, index: usize) -> SourceRecord {
3654        self.store.sources[index].clone()
3655    }
3656
3657    fn refresh_system_winget_sources(&mut self) -> Result<()> {
3658        self.store = match self.source_mode {
3659            EffectiveSourceMode::Private => self.store.clone(),
3660            EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?,
3661            EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?,
3662        };
3663        Ok(())
3664    }
3665
3666    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3667        self.refresh_system_winget_sources()?;
3668        let selected_names: Vec<_> = if source_name.is_none() {
3669            self.store.sources.iter().map(|source| source.name.clone()).collect()
3670        } else {
3671            self.resolve_source_indexes(source_name)?
3672                .into_iter()
3673                .map(|index| self.store.sources[index].name.clone())
3674                .collect()
3675        };
3676
3677        let detail = system_winget_update_sources(source_name)?;
3678        self.refresh_system_winget_sources()?;
3679
3680        Ok(selected_names
3681            .into_iter()
3682            .filter_map(|name| {
3683                self.store
3684                    .sources
3685                    .iter()
3686                    .find(|source| source.name.eq_ignore_ascii_case(&name))
3687                    .map(|source| SourceUpdateResult {
3688                        name: source.name.clone(),
3689                        kind: source.kind,
3690                        detail: detail.clone(),
3691                    })
3692            })
3693            .collect())
3694    }
3695}
3696
3697#[derive(Debug)]
3698struct CachedBytes {
3699    path: PathBuf,
3700    bytes: Vec<u8>,
3701}
3702
3703#[derive(Debug, Clone)]
3704struct V1SearchRow {
3705    manifest_rowid: i64,
3706    package_rowid: i64,
3707    version: String,
3708    channel: String,
3709    id: String,
3710    name: String,
3711    moniker: Option<String>,
3712    match_criteria: Option<String>,
3713}
3714
3715#[derive(Debug, Clone)]
3716struct V1VersionRow {
3717    version: String,
3718    channel: String,
3719    pathpart_id: i64,
3720    manifest_hash: Option<String>,
3721}
3722
3723#[derive(Debug, Clone)]
3724struct V2SearchRow {
3725    package_rowid: i64,
3726    package_hash: String,
3727    id: String,
3728    name: String,
3729    moniker: Option<String>,
3730    version: String,
3731    match_criteria: Option<String>,
3732}
3733
3734fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3735    query.query.is_some()
3736        || query.id.is_some()
3737        || query.name.is_some()
3738        || query.moniker.is_some()
3739        || query.tag.is_some()
3740        || query.command.is_some()
3741        || query.source.is_some()
3742}
3743
3744fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3745    PackageQuery {
3746        query: query.query.clone(),
3747        id: query.id.clone(),
3748        name: query.name.clone(),
3749        moniker: query.moniker.clone(),
3750        tag: query.tag.clone(),
3751        command: query.command.clone(),
3752        source: query.source.clone(),
3753        count: Some(LIST_LOOKUP_MAX_RESULTS),
3754        exact: query.exact,
3755        version: None,
3756        channel: None,
3757        locale: None,
3758        installer_type: None,
3759        installer_architecture: None,
3760        platform: None,
3761        os_version: None,
3762        install_scope: query.install_scope.clone(),
3763    }
3764}
3765
3766fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3767    query.query.is_some() || query.id.is_some() || query.name.is_some()
3768}
3769
3770fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3771    if is_greater_than_version_marker(&package.installed_version) {
3772        return false;
3773    }
3774    package
3775        .correlated
3776        .as_ref()
3777        .and_then(|candidate| candidate.version.as_deref())
3778        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3779}
3780
3781fn is_greater_than_version_marker(version: &str) -> bool {
3782    version.trim_start().starts_with('>')
3783}
3784
3785fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3786    package.installed_version.eq_ignore_ascii_case("Unknown")
3787}
3788
3789fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3790    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
3791    // Discord). Explicit `--id` filters surface them regardless.
3792    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3793        return false;
3794    }
3795    // Hide rows whose latest catalog version has no installer the host can
3796    // run — winget refuses to upgrade past an unusable architecture.
3797    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3798        return false;
3799    }
3800    installed_package_has_upgrade(package)
3801        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3802}
3803
3804/// Collapses installed entries that correlate to the same catalog package id
3805/// (case-insensitive within a source) down to a single representative — the
3806/// one with the highest installed version. Uncorrelated entries pass through
3807/// untouched; they can't appear in `upgrade` output anyway (the upgrade
3808/// filter requires a correlation), so they're effectively a no-op here.
3809fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3810    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3811    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3812
3813    for package in packages {
3814        match package.correlated.as_ref() {
3815            Some(candidate) => {
3816                let key = (
3817                    candidate.id.to_ascii_lowercase(),
3818                    candidate.source_name.to_ascii_lowercase(),
3819                );
3820                let keep_new = match by_id.get(&key) {
3821                    Some(existing) => {
3822                        // Prefer rows whose `installed_version` was remapped to
3823                        // a catalog Version (via aMiV/aMaV). Comparing a raw
3824                        // ARP DisplayVersion like `40.10.18029` against the
3825                        // catalog's `10.0.300` is meaningless and would also
3826                        // hide the row that legitimately upgrades to it.
3827                        match (
3828                            package.installed_version_canonical,
3829                            existing.installed_version_canonical,
3830                        ) {
3831                            (true, false) => true,
3832                            (false, true) => false,
3833                            _ => {
3834                                compare_version(&package.installed_version, &existing.installed_version)
3835                                    == Ordering::Greater
3836                            }
3837                        }
3838                    }
3839                    None => true,
3840                };
3841                if keep_new {
3842                    by_id.insert(key, package);
3843                }
3844            }
3845            None => uncorrelated.push(package),
3846        }
3847    }
3848
3849    let mut result = uncorrelated;
3850    result.extend(by_id.into_values());
3851    result
3852}
3853
3854fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3855    let mut source_specific = None;
3856    let mut source_agnostic = None;
3857
3858    for pin in pins {
3859        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3860            continue;
3861        }
3862
3863        if pin.source_id.is_empty() {
3864            if source_agnostic.is_none() {
3865                source_agnostic = Some(pin);
3866            }
3867        } else if item
3868            .source_name
3869            .as_deref()
3870            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3871        {
3872            source_specific = Some(pin);
3873            break;
3874        }
3875    }
3876
3877    source_specific.or(source_agnostic)
3878}
3879
3880fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3881    let Some(available_version) = item.available_version.as_deref() else {
3882        return false;
3883    };
3884    let Some(pin) = find_applicable_pin(item, pins) else {
3885        return false;
3886    };
3887
3888    match pin.pin_type {
3889        PinType::Blocking => true,
3890        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3891    }
3892}
3893
3894fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3895    if pattern.trim().is_empty() || pattern == "*" {
3896        return true;
3897    }
3898
3899    if !pattern.contains('*') {
3900        return version.eq_ignore_ascii_case(pattern);
3901    }
3902
3903    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3904        let prefix = &pattern[..pattern.len() - 1];
3905        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3906    }
3907
3908    version.eq_ignore_ascii_case(pattern)
3909}
3910
3911fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch {
3912    let available_version = package.correlated.as_ref().and_then(|candidate| {
3913        candidate.version.as_ref().and_then(|candidate_version| {
3914            if !is_greater_than_version_marker(&package.installed_version)
3915                && (installed_package_has_unknown_version(&package)
3916                    || compare_version(candidate_version, &package.installed_version) == Ordering::Greater)
3917            {
3918                Some(candidate_version.clone())
3919            } else {
3920                None
3921            }
3922        })
3923    });
3924    let mut source_name = package
3925        .correlated
3926        .as_ref()
3927        .map(|candidate| candidate.source_name.clone())
3928        .filter(|value| !value.is_empty());
3929    let mut id = package
3930        .correlated
3931        .as_ref()
3932        .map(|candidate| candidate.id.clone())
3933        .unwrap_or_else(|| package.local_id.clone());
3934
3935    if source_name.is_none()
3936        && let Some(package_id) = package
3937            .winget_package_identifier
3938            .as_deref()
3939            .filter(|value| !value.trim().is_empty())
3940    {
3941        id = package_id.to_owned();
3942        source_name = package
3943            .winget_source_identifier
3944            .as_deref()
3945            .filter(|value| !value.trim().is_empty())
3946            .map(|identifier| source_name_from_identifier(identifier, sources));
3947    }
3948
3949    if source_name.is_none()
3950        && let Some((local_package_id, local_source_name)) =
3951            winget_package_identity_from_local_id(&package.local_id, sources)
3952    {
3953        id = local_package_id;
3954        source_name = Some(local_source_name);
3955    }
3956
3957    ListMatch {
3958        name: package.name,
3959        id,
3960        local_id: package.local_id,
3961        installed_version: package.installed_version,
3962        available_version,
3963        source_name,
3964        publisher: package.publisher,
3965        scope: package.scope,
3966        installer_category: package.installer_category,
3967        install_location: package.install_location,
3968        package_family_names: package.package_family_names,
3969        product_codes: package.product_codes,
3970        upgrade_codes: package.upgrade_codes,
3971    }
3972}
3973
3974fn suppress_duplicate_available_versions(matches: Vec<ListMatch>) -> Vec<ListMatch> {
3975    let mut grouped: HashMap<(String, String), Vec<usize>> = HashMap::new();
3976    for (index, item) in matches.iter().enumerate() {
3977        let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else {
3978            continue;
3979        };
3980        grouped
3981            .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
3982            .or_default()
3983            .push(index);
3984    }
3985
3986    let mut keep = BTreeSet::new();
3987    for indexes in grouped.values().filter(|indexes| indexes.len() > 1) {
3988        let Some(best) = indexes
3989            .iter()
3990            .map(|index| matches[*index].installed_version.as_str())
3991            .max_by(|left, right| compare_version(left, right))
3992        else {
3993            continue;
3994        };
3995        for index in indexes {
3996            if compare_version(&matches[*index].installed_version, best) == Ordering::Equal {
3997                keep.insert(*index);
3998            }
3999        }
4000    }
4001
4002    matches
4003        .into_iter()
4004        .enumerate()
4005        .map(|(index, mut item)| {
4006            if item.available_version.is_some() && !keep.contains(&index) {
4007                let is_duplicate = item
4008                    .source_name
4009                    .as_ref()
4010                    .map(|source_name| {
4011                        grouped
4012                            .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4013                            .is_some_and(|indexes| indexes.len() > 1)
4014                    })
4015                    .unwrap_or(false);
4016                if is_duplicate {
4017                    item.available_version = None;
4018                }
4019            }
4020            item
4021        })
4022        .collect()
4023}
4024
4025fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String {
4026    sources
4027        .iter()
4028        .find(|source| source.identifier.eq_ignore_ascii_case(identifier))
4029        .map(|source| source.name.clone())
4030        .unwrap_or_else(|| identifier.to_owned())
4031}
4032
4033fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> {
4034    let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1);
4035    for source in sources {
4036        let source_suffix = format!("_{}", source.identifier);
4037        let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else {
4038            continue;
4039        };
4040        if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) {
4041            continue;
4042        }
4043        if source_identifier_start_index <= package_id_start_index {
4044            return None;
4045        }
4046        let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned();
4047        if package_id.trim().is_empty() {
4048            return None;
4049        }
4050        return Some((package_id, source.name.clone()));
4051    }
4052    None
4053}
4054
4055fn list_sort_weight(package: &InstalledPackage) -> usize {
4056    if package.local_id.starts_with("ARP\\") {
4057        0
4058    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
4059        1
4060    } else {
4061        2
4062    }
4063}
4064
4065fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
4066    let correlated = package.correlated.as_ref();
4067
4068    if let Some(value) = &query.id {
4069        let local_match = matches_text(&package.local_id, value, query.exact);
4070        let correlated_match = correlated
4071            .map(|candidate| matches_text(&candidate.id, value, query.exact))
4072            .unwrap_or(false);
4073        if !local_match && !correlated_match {
4074            return false;
4075        }
4076    }
4077
4078    if let Some(value) = &query.name
4079        && !matches_text(&package.name, value, query.exact)
4080    {
4081        return false;
4082    }
4083
4084    if let Some(value) = &query.product_code
4085        && !package
4086            .product_codes
4087            .iter()
4088            .any(|code| code.eq_ignore_ascii_case(value))
4089    {
4090        return false;
4091    }
4092
4093    if let Some(value) = &query.version
4094        && !package.installed_version.eq_ignore_ascii_case(value)
4095    {
4096        return false;
4097    }
4098
4099    if let Some(value) = &query.query {
4100        let local_match =
4101            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
4102        let correlated_match = correlated
4103            .map(|candidate| {
4104                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
4105            })
4106            .unwrap_or(false);
4107        if !local_match && !correlated_match {
4108            return false;
4109        }
4110    }
4111
4112    if let Some(source) = &query.source
4113        && correlated
4114            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
4115            .unwrap_or(true)
4116    {
4117        return false;
4118    }
4119
4120    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
4121        return false;
4122    }
4123
4124    true
4125}
4126
4127fn correlate_installed_package(
4128    package: &InstalledPackage,
4129    candidates: &[SearchMatch],
4130    allow_loose_name_match: bool,
4131) -> Option<SearchMatch> {
4132    // MSIX entries can only be correlated through their PackageFamilyName.
4133    // That lookup happens earlier in `correlate_installed_via_index`; if it
4134    // didn't find a match, name-based fallback is wrong — the catalog
4135    // doesn't carry this MSIX's identity, so any name collision (e.g. the
4136    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
4137    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
4138    // doesn't make.
4139    if package.local_id.starts_with("MSIX\\") {
4140        return None;
4141    }
4142
4143    let installed_name = normalize_correlation_name(&package.name);
4144    let installed_name_lower = package.name.to_ascii_lowercase();
4145    let candidate_names = correlation_name_candidates(&package.name);
4146
4147    let mut scored: Vec<(i32, SearchMatch)> = candidates
4148        .iter()
4149        .filter_map(|candidate| {
4150            let candidate_name = normalize_correlation_name(&candidate.name);
4151            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
4152                1000
4153            } else if candidate_names.iter().any(|name| {
4154                let normalized = normalize_correlation_name(name);
4155                normalized == candidate_name
4156            }) {
4157                900
4158            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
4159                700
4160            } else {
4161                0
4162            };
4163
4164            if base_score == 0 {
4165                return None;
4166            }
4167
4168            // Tiebreaker so we pick the *right* candidate when multiple catalog
4169            // entries collapse to the same normalized name or all loose-match a
4170            // long installed name. Two failure modes this addresses:
4171            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
4172            //     alphanumeric filter strips `+`/`-`), so both score 900 and
4173            //     `max_by_key` arbitrarily picked the latter.
4174            //   • Loose substring match picks up `Studio` inside
4175            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
4176            //     `Microsoft.DotNet.SDK.10` when both score 700.
4177            // The bonus rewards catalog names that appear verbatim in the
4178            // installed display name — strongly when they anchor the start,
4179            // weaker when they're embedded.
4180            let candidate_lower = candidate.name.to_ascii_lowercase();
4181            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
4182                100
4183            } else if installed_name_lower.contains(&candidate_lower) {
4184                50
4185            } else {
4186                0
4187            };
4188
4189            Some((base_score + prefix_bonus, candidate.clone()))
4190        })
4191        .collect();
4192
4193    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
4194    let mut iter = scored.into_iter();
4195    let (top_score, top_match) = iter.next()?;
4196
4197    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
4198    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
4199    // normalize their names to `git` and tie at the same score — the only
4200    // signal that disambiguates them, publisher, isn't part of this scoring
4201    // function. winget refuses the correlation in this case (the install
4202    // shows up with an empty Source); we do the same to avoid manufacturing
4203    // a fake upgrade against the wrong package.
4204    if let Some((next_score, _)) = iter.next()
4205        && next_score == top_score
4206    {
4207        return None;
4208    }
4209    Some(top_match)
4210}
4211
4212fn correlation_name_candidates(name: &str) -> Vec<String> {
4213    let mut candidates = Vec::new();
4214    candidates.push(name.trim().to_owned());
4215
4216    let mut trimmed = name.trim().to_owned();
4217    if let Some(index) = trimmed.find(" (") {
4218        trimmed.truncate(index);
4219    }
4220
4221    let mut words = Vec::new();
4222    for token in trimmed.split_whitespace() {
4223        let lower = token
4224            .trim_matches(|ch: char| ch == '(' || ch == ')')
4225            .to_ascii_lowercase();
4226        if !words.is_empty()
4227            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
4228        {
4229            break;
4230        }
4231        words.push(token);
4232    }
4233
4234    if !words.is_empty() {
4235        candidates.push(words.join(" "));
4236    }
4237
4238    candidates.sort();
4239    candidates.dedup();
4240    candidates
4241}
4242
4243fn normalize_correlation_name(value: &str) -> String {
4244    value
4245        .chars()
4246        .filter(|ch| ch.is_ascii_alphanumeric())
4247        .flat_map(|ch| ch.to_lowercase())
4248        .collect()
4249}
4250
4251struct V2PackageMetadata {
4252    rowid: i64,
4253    id: String,
4254    name: String,
4255    moniker: Option<String>,
4256    latest_version: String,
4257    package_hash: String,
4258}
4259
4260/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
4261/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
4262/// and the field that produced the match. This is winget's primary correlation
4263/// path; falling back to display-name matching is only correct for ARP entries
4264/// that lack all three identifiers.
4265fn lookup_identity_match_v2(
4266    connection: &Connection,
4267    package: &InstalledPackage,
4268) -> Result<Option<(i64, &'static str)>> {
4269    for pfn in &package.package_family_names {
4270        if let Some(rowid) = query_optional_value(
4271            connection,
4272            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
4273            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
4274            |row| row_i64(row, 0),
4275        )? {
4276            return Ok(Some((rowid, "PackageFamilyName")));
4277        }
4278    }
4279    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
4280    // identity — it stays stable across versions, while ProductCode changes
4281    // every release. When a single ProductCode happens to also be listed
4282    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
4283    // installer rows from earlier LTS-line ProductCodes, while the v24
4284    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
4285    // the upgrade-side match represents the user's "package family" — the
4286    // one winget uses.
4287    for code in &package.upgrade_codes {
4288        if let Some(rowid) = query_optional_value(
4289            connection,
4290            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
4291            vec![SqlValue::Text(code.to_ascii_lowercase())],
4292            |row| row_i64(row, 0),
4293        )? {
4294            return Ok(Some((rowid, "UpgradeCode")));
4295        }
4296    }
4297    for code in &package.product_codes {
4298        if let Some(rowid) = query_optional_value(
4299            connection,
4300            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
4301            vec![SqlValue::Text(code.to_ascii_lowercase())],
4302            |row| row_i64(row, 0),
4303        )? {
4304            return Ok(Some((rowid, "ProductCode")));
4305        }
4306    }
4307    Ok(None)
4308}
4309
4310fn installed_db_identity_tables_present(connection: &Connection) -> Result<bool> {
4311    Ok(table_exists(connection, "manifest")?
4312        && table_exists(connection, "ids")?
4313        && table_exists(connection, "names")?
4314        && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?)
4315            || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?)
4316            || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?)))
4317}
4318
4319fn table_exists(connection: &Connection, name: &str) -> Result<bool> {
4320    query_optional_value(
4321        connection,
4322        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
4323        vec![SqlValue::Text(name.to_owned())],
4324        |row| row_i64(row, 0),
4325    )
4326    .map(|value| value.is_some())
4327}
4328
4329fn lookup_installed_db_identity_match(
4330    connection: &Connection,
4331    package: &InstalledPackage,
4332) -> Result<Option<(String, String, &'static str)>> {
4333    for pfn in &package.package_family_names {
4334        if let Some(hit) =
4335            lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")?
4336        {
4337            return Ok(Some(hit));
4338        }
4339    }
4340    for code in &package.upgrade_codes {
4341        if let Some(hit) = lookup_installed_db_identity_match_value(
4342            connection,
4343            "upgradecodes",
4344            "upgradecodes_map",
4345            "upgradecode",
4346            code,
4347            "UpgradeCode",
4348        )? {
4349            return Ok(Some(hit));
4350        }
4351    }
4352    for code in &package.product_codes {
4353        if let Some(hit) = lookup_installed_db_identity_match_value(
4354            connection,
4355            "productcodes",
4356            "productcodes_map",
4357            "productcode",
4358            code,
4359            "ProductCode",
4360        )? {
4361            return Ok(Some(hit));
4362        }
4363    }
4364    Ok(None)
4365}
4366
4367fn lookup_installed_db_identity_match_value(
4368    connection: &Connection,
4369    value_table: &'static str,
4370    map_table: &'static str,
4371    value_column: &'static str,
4372    value: &str,
4373    matched_by: &'static str,
4374) -> Result<Option<(String, String, &'static str)>> {
4375    let sql = format!(
4376        "SELECT ids.id, names.name \
4377         FROM {map_table} map \
4378         JOIN {value_table} value ON map.{value_column} = value.rowid \
4379         JOIN manifest ON map.manifest = manifest.rowid \
4380         JOIN ids ON manifest.id = ids.rowid \
4381         JOIN names ON manifest.name = names.rowid \
4382         WHERE value.{value_column} = ?1 \
4383         LIMIT 1"
4384    );
4385    query_optional_value(
4386        connection,
4387        &sql,
4388        vec![SqlValue::Text(value.to_ascii_lowercase())],
4389        |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)),
4390    )
4391}
4392
4393/// Resolves a v2 package by its catalog id back to its rowid + hash so the
4394/// post-correlation enrichment pass can load versionData for rows that
4395/// correlated through the name-based fallback (which throws the rowid away).
4396fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
4397    query_optional_value(
4398        connection,
4399        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
4400        vec![SqlValue::Text(package_id.to_owned())],
4401        |row| {
4402            Ok(V2PackageMetadata {
4403                rowid: row_i64(row, 0)?,
4404                id: row_string(row, 1)?,
4405                name: row_string(row, 2)?,
4406                moniker: row_opt_string(row, 3)?,
4407                latest_version: row_string(row, 4)?,
4408                package_hash: row_hex_string(row, 5)?,
4409            })
4410        },
4411    )
4412}
4413
4414/// MSIX entries whose Windows resource-string display name (e.g.
4415/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
4416/// would otherwise leak that placeholder into pinget's output. winget
4417/// resolves it via `Windows.Management.Deployment` and shows the catalog's
4418/// `PackageName`; we mirror that by substituting the correlated catalog
4419/// name once the package is correlated, which is the parity-faithful
4420/// substitute we can produce without taking a WinRT dependency.
4421fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
4422    if !package.local_id.starts_with("MSIX\\") {
4423        return;
4424    }
4425    if !package.name.starts_with("ms-resource:") {
4426        return;
4427    }
4428    let Some(candidate) = package.correlated.as_ref() else {
4429        return;
4430    };
4431    if !candidate.name.is_empty() {
4432        package.name = candidate.name.clone();
4433    }
4434}
4435
4436fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
4437    query_optional_value(
4438        connection,
4439        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
4440        vec![SqlValue::Integer(package_rowid)],
4441        |row| {
4442            Ok(V2PackageMetadata {
4443                rowid: package_rowid,
4444                id: row_string(row, 0)?,
4445                name: row_string(row, 1)?,
4446                moniker: row_opt_string(row, 2)?,
4447                latest_version: row_string(row, 3)?,
4448                package_hash: row_hex_string(row, 4)?,
4449            })
4450        },
4451    )
4452}
4453
4454fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
4455    query_optional_value(
4456        connection,
4457        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
4458        Vec::new(),
4459        |row| row_i64(row, 0),
4460    )
4461    .ok()
4462    .flatten()
4463    .is_some()
4464}
4465
4466/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
4467/// pair matches the installed package — if and only if the match is
4468/// unique. Multiple matches mean the normalization wasn't strong enough to
4469/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
4470/// distinct catalog packages); winget refuses to correlate in that case
4471/// and we do the same.
4472fn lookup_unique_normalized_identity(
4473    connection: &Connection,
4474    norm_name: &str,
4475    norm_publisher: &str,
4476) -> Result<Option<i64>> {
4477    let rows: Vec<i64> = query_rows(
4478        connection,
4479        "SELECT DISTINCT n.package FROM norm_names2 n \
4480         WHERE n.norm_name = ?1 \
4481         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
4482         LIMIT 2",
4483        vec![
4484            SqlValue::Text(norm_name.to_owned()),
4485            SqlValue::Text(norm_publisher.to_owned()),
4486        ],
4487        |row| row_i64(row, 0),
4488    )?;
4489    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
4490}
4491
4492fn v2_identity_tables_present(connection: &Connection) -> bool {
4493    query_optional_value(
4494        connection,
4495        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
4496        Vec::new(),
4497        |row| row_i64(row, 0),
4498    )
4499    .ok()
4500    .flatten()
4501    .is_some()
4502}
4503
4504/// Translates an ARP DisplayVersion to the catalog Version it maps to.
4505/// For packages like the .NET SDK or Python the ARP version is an MSI build
4506/// number (e.g. `10.1.826.23019`) unrelated to the public version
4507/// (`10.0.108`). winget records the ARP range per catalog version in
4508/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
4509/// contains the installed version.
4510fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4511    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4512        return None;
4513    }
4514    for entry in entries {
4515        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
4516            continue;
4517        };
4518        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
4519        {
4520            return Some(entry.version.clone());
4521        }
4522    }
4523    None
4524}
4525
4526/// Returns `< latest>` when ARP range metadata proves the installed version is
4527/// below the newest catalog version, but the raw DisplayVersion is too coarse
4528/// to map into a concrete aMiV/aMaV bucket. Snagit 2025 reports `25.4.1` while
4529/// the catalog's latest ARP build is `25.4.1.10325`; winget renders that as
4530/// installed `< 25.4.1` with available `25.4.1`.
4531fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4532    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4533        return None;
4534    }
4535
4536    let latest = entries
4537        .iter()
4538        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4539        .max_by(|a, b| compare_version(&a.version, &b.version))?;
4540    if compare_version(arp_version, &latest.version) != Ordering::Equal {
4541        return None;
4542    }
4543    let latest_min = latest.arp_min_version.as_deref()?;
4544    if compare_version(arp_version, latest_min) != Ordering::Less {
4545        return None;
4546    }
4547
4548    let previous_max = entries
4549        .iter()
4550        .filter(|entry| compare_version(&entry.version, &latest.version) == Ordering::Less)
4551        .filter_map(|entry| entry.arp_max_version.as_deref())
4552        .max_by(|left, right| compare_version(left, right));
4553    if previous_max.is_some_and(|max| compare_version(arp_version, max) != Ordering::Greater) {
4554        return None;
4555    }
4556
4557    Some(format!("< {}", latest.version))
4558}
4559
4560/// Returns `> latest` when ARP range metadata proves the installed package
4561/// version is newer than the newest catalog version that has ARP bounds. This
4562/// happens for inbox MSIX frameworks that Windows services beyond winget's
4563/// latest user-facing catalog mapping.
4564fn greater_than_latest_arp_anchored_version(
4565    entries: &[PackageVersionDataEntry],
4566    arp_version: &str,
4567    catalog_latest_version: &str,
4568) -> Option<String> {
4569    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4570        return None;
4571    }
4572
4573    let latest = entries
4574        .iter()
4575        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4576        .max_by(|a, b| compare_version(&a.version, &b.version))?;
4577    let latest_max = latest.arp_max_version.as_deref()?;
4578    if !latest.version.eq_ignore_ascii_case(catalog_latest_version) {
4579        return None;
4580    }
4581    if !has_different_leading_version_number(latest_max, &latest.version) {
4582        return None;
4583    }
4584    if compare_version(arp_version, latest_max) != Ordering::Greater {
4585        return None;
4586    }
4587
4588    Some(format!("> {}", latest.version))
4589}
4590
4591fn has_different_leading_version_number(left: &str, right: &str) -> bool {
4592    let Some(left) = leading_version_number(left) else {
4593        return false;
4594    };
4595    let Some(right) = leading_version_number(right) else {
4596        return false;
4597    };
4598    !left.eq_ignore_ascii_case(right)
4599}
4600
4601fn leading_version_number(value: &str) -> Option<&str> {
4602    let trimmed = value.trim_start();
4603    let length = trimmed
4604        .chars()
4605        .take_while(|ch| ch.is_ascii_digit())
4606        .map(char::len_utf8)
4607        .sum();
4608    (length > 0).then_some(&trimmed[..length])
4609}
4610
4611/// Returns the latest catalog Version that carries ARP-range metadata
4612/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
4613/// publish "internal" version rows whose `v` is an MSI build number
4614/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
4615/// upgrade targets — when the installed side was matched through an ARP
4616/// range, the available side must be a peer that also exposes ARP bounds so
4617/// the two versions compare on the same scale. Returns `None` when no entry
4618/// has bounds; the caller should fall back to `packages.latest_version`.
4619fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
4620    entries
4621        .iter()
4622        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4623        .max_by(|a, b| compare_version(&a.version, &b.version))
4624        .map(|entry| entry.version.clone())
4625}
4626
4627#[cfg(windows)]
4628fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4629    let mut packages = Vec::new();
4630    let mut seen = BTreeSet::new();
4631
4632    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
4633    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
4634
4635    // MSI registers UpgradeCode → ProductCode mappings under
4636    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
4637    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
4638    // entries don't expose `UpgradeCode` directly, so winget reads it from
4639    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
4640    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
4641    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
4642    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
4643
4644    if machine {
4645        collect_uninstall_view(
4646            &mut packages,
4647            &mut seen,
4648            &upgrade_codes,
4649            RegKey::predef(HKEY_LOCAL_MACHINE),
4650            "Machine",
4651            "X64",
4652            KEY_READ | KEY_WOW64_64KEY,
4653        )?;
4654        collect_uninstall_view(
4655            &mut packages,
4656            &mut seen,
4657            &upgrade_codes,
4658            RegKey::predef(HKEY_LOCAL_MACHINE),
4659            "Machine",
4660            "X86",
4661            KEY_READ | KEY_WOW64_32KEY,
4662        )?;
4663        collect_appmodel_packages(
4664            &mut packages,
4665            &mut seen,
4666            RegKey::predef(HKEY_LOCAL_MACHINE),
4667            "Machine",
4668            KEY_READ | KEY_WOW64_64KEY,
4669        )?;
4670    }
4671
4672    if user {
4673        collect_uninstall_view(
4674            &mut packages,
4675            &mut seen,
4676            &upgrade_codes,
4677            RegKey::predef(HKEY_CURRENT_USER),
4678            "User",
4679            "X64",
4680            KEY_READ,
4681        )?;
4682        collect_appmodel_packages(
4683            &mut packages,
4684            &mut seen,
4685            RegKey::predef(HKEY_CURRENT_USER),
4686            "User",
4687            KEY_READ,
4688        )?;
4689    }
4690
4691    Ok(packages)
4692}
4693
4694/// Builds a `ProductCode → UpgradeCode` map (both in standard
4695/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
4696/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
4697/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
4698/// flipped ProductCodes registered under that UpgradeCode.
4699#[cfg(windows)]
4700fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4701    let mut map = HashMap::new();
4702    if include_machine {
4703        collect_msi_upgrade_codes_from(
4704            RegKey::predef(HKEY_LOCAL_MACHINE),
4705            r"SOFTWARE\Classes\Installer\UpgradeCodes",
4706            KEY_READ | KEY_WOW64_64KEY,
4707            &mut map,
4708        );
4709    }
4710    if include_user {
4711        collect_msi_upgrade_codes_from(
4712            RegKey::predef(HKEY_CURRENT_USER),
4713            r"Software\Microsoft\Installer\UpgradeCodes",
4714            KEY_READ,
4715            &mut map,
4716        );
4717    }
4718    map
4719}
4720
4721#[cfg(windows)]
4722fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4723    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4724        return;
4725    };
4726    for key_name in upgrade_codes.enum_keys().flatten() {
4727        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4728            continue;
4729        };
4730        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4731            continue;
4732        };
4733        for (value_name, _) in subkey.enum_values().flatten() {
4734            if let Some(product_code) = unflip_packed_guid(&value_name) {
4735                // ARP-side keys are likely to win the latest insert; first one
4736                // is fine — we just need *any* mapping. Use `entry` so the
4737                // first writer per ProductCode wins to stay deterministic.
4738                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4739            }
4740        }
4741    }
4742}
4743
4744/// Converts the 32-character "packed GUID" used inside the MSI Installer
4745/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
4746/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
4747/// then eight 2-char byte pairs) of the GUID's hex representation.
4748#[cfg(windows)]
4749fn unflip_packed_guid(packed: &str) -> Option<String> {
4750    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4751        return None;
4752    }
4753    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4754    let bytes = packed.as_bytes();
4755    let mut reversed = String::with_capacity(32);
4756    let mut offset = 0;
4757    for size in CHUNKS {
4758        let chunk = &bytes[offset..offset + size];
4759        for &c in chunk.iter().rev() {
4760            reversed.push(c as char);
4761        }
4762        offset += size;
4763    }
4764    let r = reversed.to_ascii_lowercase();
4765    Some(format!(
4766        "{{{}-{}-{}-{}-{}}}",
4767        &r[0..8],
4768        &r[8..12],
4769        &r[12..16],
4770        &r[16..20],
4771        &r[20..32]
4772    ))
4773}
4774
4775#[cfg(not(windows))]
4776fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4777    Ok(Vec::new())
4778}
4779
4780#[cfg(windows)]
4781fn collect_uninstall_view(
4782    packages: &mut Vec<InstalledPackage>,
4783    seen: &mut BTreeSet<String>,
4784    upgrade_code_map: &HashMap<String, String>,
4785    root: RegKey,
4786    scope: &str,
4787    arch: &str,
4788    flags: u32,
4789) -> Result<()> {
4790    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4791
4792    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4793        Ok(key) => key,
4794        Err(_) => return Ok(()),
4795    };
4796
4797    for key_name in uninstall.enum_keys().flatten() {
4798        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4799            Ok(key) => key,
4800            Err(_) => continue,
4801        };
4802        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4803        {
4804            continue;
4805        }
4806
4807        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4808            continue;
4809        };
4810
4811        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4812        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4813        let publisher = read_reg_string(&subkey, "Publisher");
4814        let install_location = read_reg_string(&subkey, "InstallLocation");
4815        let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier");
4816        let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier");
4817        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4818            .into_iter()
4819            .collect::<Vec<_>>();
4820        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4821        if !product_codes.iter().any(|code| code.eq_ignore_ascii_case(&key_name)) {
4822            product_codes.push(key_name.to_ascii_lowercase());
4823        }
4824        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4825        if upgrade_codes.is_empty() {
4826            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
4827            // Installer registry hive so identity correlation can fall back to
4828            // upgradecodes2 when productcodes2 picks the wrong sibling.
4829            for code in &product_codes {
4830                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4831                    upgrade_codes.push(uc.clone());
4832                    break;
4833                }
4834            }
4835        }
4836        let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4837            .as_deref()
4838            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4839        {
4840            // Honor the WinGet ARP signal so uninstall flows through
4841            // uninstall_portable (clean dir + registry removal) instead of
4842            // delegating the WinGet portable UninstallString to a winget
4843            // subprocess that only fully cleans up entries winget itself
4844            // installed.
4845            Some("portable".to_owned())
4846        } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4847            Some("msi".to_owned())
4848        } else if key_name.starts_with("MSIX\\") {
4849            Some("msix".to_owned())
4850        } else {
4851            Some("exe".to_owned())
4852        };
4853
4854        let dedupe_key = format!(
4855            "{}|{}|{}|{}",
4856            local_id,
4857            name.to_ascii_lowercase(),
4858            installed_version.to_ascii_lowercase(),
4859            publisher.clone().unwrap_or_default().to_ascii_lowercase()
4860        );
4861        if !seen.insert(dedupe_key) {
4862            continue;
4863        }
4864
4865        packages.push(InstalledPackage {
4866            name,
4867            local_id,
4868            installed_version,
4869            publisher,
4870            scope: Some(scope.to_owned()),
4871            installer_category,
4872            install_location,
4873            winget_package_identifier,
4874            winget_source_identifier,
4875            package_family_names,
4876            product_codes,
4877            upgrade_codes,
4878            correlated: None,
4879            installed_version_canonical: false,
4880            correlated_requires_explicit_upgrade: false,
4881            correlated_lacks_compatible_installer: false,
4882        });
4883    }
4884
4885    Ok(())
4886}
4887
4888#[cfg(windows)]
4889fn collect_appmodel_packages(
4890    packages: &mut Vec<InstalledPackage>,
4891    seen: &mut BTreeSet<String>,
4892    root: RegKey,
4893    scope: &str,
4894    flags: u32,
4895) -> Result<()> {
4896    const APPMODEL_PACKAGES_PATH: &str =
4897        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4898
4899    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4900        Ok(key) => key,
4901        Err(_) => return Ok(()),
4902    };
4903
4904    for key_name in appmodel.enum_keys().flatten() {
4905        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4906            Ok(key) => key,
4907            Err(_) => continue,
4908        };
4909
4910        let install_location = read_reg_string(&subkey, "PackageRootFolder");
4911        if install_location.as_deref().is_some_and(is_windows_system_path) {
4912            continue;
4913        }
4914
4915        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4916            continue;
4917        };
4918        if is_msix_split_resource_package(&metadata.resource_id) {
4919            continue;
4920        }
4921
4922        let Some(name) = read_reg_string(&subkey, "DisplayName")
4923            .or_else(|| read_appmodel_display_name(&subkey))
4924            .filter(|value| !value.is_empty())
4925        else {
4926            continue;
4927        };
4928
4929        let local_id = format!(r"MSIX\{key_name}");
4930        let dedupe_key = format!(
4931            "{}|{}|{}",
4932            local_id,
4933            name.to_ascii_lowercase(),
4934            metadata.version.to_ascii_lowercase()
4935        );
4936        if !seen.insert(dedupe_key) {
4937            continue;
4938        }
4939
4940        packages.push(InstalledPackage {
4941            name,
4942            local_id,
4943            installed_version: metadata.version,
4944            publisher: None,
4945            scope: Some(scope.to_owned()),
4946            installer_category: Some("msix".to_owned()),
4947            install_location,
4948            winget_package_identifier: None,
4949            winget_source_identifier: None,
4950            package_family_names: vec![metadata.family_name],
4951            product_codes: Vec::new(),
4952            upgrade_codes: Vec::new(),
4953            correlated: None,
4954            installed_version_canonical: false,
4955            correlated_requires_explicit_upgrade: false,
4956            correlated_lacks_compatible_installer: false,
4957        });
4958    }
4959
4960    Ok(())
4961}
4962
4963#[cfg(windows)]
4964struct ParsedMsixPackageFullName {
4965    version: String,
4966    resource_id: String,
4967    family_name: String,
4968}
4969
4970#[cfg(windows)]
4971fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4972    let segments = value.split('_').collect::<Vec<_>>();
4973    if segments.len() < 5 {
4974        return None;
4975    }
4976
4977    let name = segments[..segments.len() - 4].join("_");
4978    if name.is_empty() {
4979        return None;
4980    }
4981
4982    let version = segments[segments.len() - 4].trim();
4983    let resource_id = segments[segments.len() - 2].trim();
4984    let publisher_id = segments[segments.len() - 1].trim();
4985    if version.is_empty() || publisher_id.is_empty() {
4986        return None;
4987    }
4988
4989    let family_name = if resource_id.is_empty() {
4990        format!("{name}_{publisher_id}")
4991    } else {
4992        format!("{name}_{resource_id}_{publisher_id}")
4993    };
4994
4995    Some(ParsedMsixPackageFullName {
4996        version: version.to_owned(),
4997        resource_id: resource_id.to_owned(),
4998        family_name,
4999    })
5000}
5001
5002#[cfg(windows)]
5003fn read_appmodel_display_name(package_key: &RegKey) -> Option<String> {
5004    let app_key = package_key.open_subkey(r"App\Capabilities").ok()?;
5005    read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName"))
5006}
5007
5008#[cfg(windows)]
5009fn is_msix_split_resource_package(resource_id: &str) -> bool {
5010    resource_id.trim().to_ascii_lowercase().starts_with("split.")
5011}
5012
5013#[cfg(windows)]
5014fn is_windows_system_path(path: &str) -> bool {
5015    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
5016}
5017
5018#[cfg(windows)]
5019fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
5020    key.get_value::<String, _>(value_name)
5021        .ok()
5022        .map(|value| value.trim().to_owned())
5023        .filter(|value| !value.is_empty())
5024}
5025
5026#[cfg(windows)]
5027fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
5028    key.get_value::<u32, _>(value_name).ok()
5029}
5030
5031fn ensure_app_dirs(app_root: &Path) -> Result<()> {
5032    fs::create_dir_all(app_root).context("failed to create app root directory")?;
5033    if uses_packaged_layout(app_root) {
5034        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
5035        let user_sources_path = packaged_user_sources_path(app_root)?;
5036        if let Some(parent) = user_sources_path.parent() {
5037            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
5038        }
5039    } else {
5040        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
5041    }
5042    Ok(())
5043}
5044
5045fn default_app_root() -> Result<PathBuf> {
5046    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
5047        return Ok(PathBuf::from(app_root));
5048    }
5049
5050    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
5051
5052    #[cfg(windows)]
5053    {
5054        if has_current_package_identity() {
5055            return Ok(local_app_data
5056                .join("Packages")
5057                .join(PACKAGED_FAMILY_NAME)
5058                .join("LocalState"));
5059        }
5060        Ok(local_app_data.join("Devolutions").join("Pinget"))
5061    }
5062
5063    #[cfg(not(windows))]
5064    {
5065        Ok(local_app_data.join("pinget"))
5066    }
5067}
5068
5069#[cfg(windows)]
5070fn has_current_package_identity() -> bool {
5071    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
5072    // process has no AppX package identity.
5073    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
5074
5075    let mut length: u32 = 0;
5076    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
5077    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
5078    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
5079    rc != APPMODEL_ERROR_NO_PACKAGE
5080}
5081
5082fn store_path(app_root: &Path) -> PathBuf {
5083    app_root.join("sources.json")
5084}
5085
5086fn user_settings_path(app_root: &Path) -> PathBuf {
5087    if uses_packaged_layout(app_root) {
5088        app_root.join("settings.json")
5089    } else {
5090        app_root.join("user-settings.json")
5091    }
5092}
5093
5094fn admin_settings_path(app_root: &Path) -> PathBuf {
5095    app_root.join("admin-settings.json")
5096}
5097
5098#[cfg(test)]
5099fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
5100    source_state_dir_with_keying(app_root, source, false)
5101}
5102
5103fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5104    if uses_packaged_layout(app_root) {
5105        return app_root
5106            .join(packaged_source_type(source.kind))
5107            .join(sanitize_path_segment(&source.identifier));
5108    }
5109
5110    if identifier_keyed {
5111        return app_root.join("sources").join(sanitize_path_segment(&source.identifier));
5112    }
5113
5114    app_root.join("sources").join(sanitize_path_segment(&source.name))
5115}
5116
5117fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5118    if uses_packaged_layout(app_root) {
5119        return app_root
5120            .join(sanitize_path_segment(&source.identifier))
5121            .join("installed.db");
5122    }
5123
5124    source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db")
5125}
5126
5127#[cfg(test)]
5128fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
5129    source_state_dir(app_root, source).join("index.db")
5130}
5131
5132fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) {
5133    if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) {
5134        source.etag = Some(value.to_owned());
5135    }
5136
5137    if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) {
5138        source.last_modified = Some(value.to_owned());
5139    }
5140}
5141
5142/// Removes the `-wal` and `-shm` sidecars that SQLite/Turso leaves next to a
5143/// database file. Used after replacing `index.db` so a stale write-ahead log
5144/// from the previous database is not replayed against the new one.
5145fn remove_sqlite_sidecars(db_path: &Path) {
5146    for suffix in ["-wal", "-shm"] {
5147        let mut sidecar = db_path.as_os_str().to_owned();
5148        sidecar.push(suffix);
5149        let _ = fs::remove_file(PathBuf::from(sidecar));
5150    }
5151}
5152
5153#[cfg(windows)]
5154const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
5155
5156#[cfg(windows)]
5157fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
5158    let source_wide = source
5159        .as_os_str()
5160        .encode_wide()
5161        .chain(std::iter::once(0))
5162        .collect::<Vec<_>>();
5163    let target_wide = target
5164        .as_os_str()
5165        .encode_wide()
5166        .chain(std::iter::once(0))
5167        .collect::<Vec<_>>();
5168    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
5169    // SAFETY: Both paths are converted to null-terminated UTF-16 buffers that
5170    // live for the duration of the call; MoveFileExW does not retain them.
5171    let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
5172    if ok == 0 {
5173        return Err(std::io::Error::last_os_error());
5174    }
5175    Ok(())
5176}
5177
5178#[cfg(windows)]
5179fn replace_file(source: &Path, target: &Path) -> Result<()> {
5180    let to_err = |error: std::io::Error| -> anyhow::Error {
5181        anyhow::Error::new(error).context(format!(
5182            "failed to replace {} with {}",
5183            target.display(),
5184            source.display()
5185        ))
5186    };
5187
5188    // Fast path: atomic replace. Succeeds whenever nothing holds the target open.
5189    let first = match move_replace_existing(source, target) {
5190        Ok(()) => return Ok(()),
5191        Err(error) => error,
5192    };
5193
5194    // A missing source or absent target is not a transient lock — fail fast
5195    // instead of spinning through the retry/stage-aside path.
5196    if !source.exists() || !target.exists() {
5197        return Err(to_err(first));
5198    }
5199
5200    // MOVEFILE_REPLACE_EXISTING is denied while another handle holds the target
5201    // open. In practice that handle is an on-access AV scan of the freshly
5202    // written file or a concurrent pinget process reading the index; both are
5203    // short-lived, so retry briefly with a small backoff before falling back.
5204    let mut last = first;
5205    for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
5206        std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
5207        match move_replace_existing(source, target) {
5208            Ok(()) => return Ok(()),
5209            Err(error) => last = error,
5210        }
5211    }
5212
5213    // Persistent holder: stage the existing index aside, then move the new file
5214    // into the freed name. Renaming the existing file succeeds even while a
5215    // reader keeps its handle, because SQLite/Turso opens with FILE_SHARE_DELETE.
5216    // The staged copy is only removed once the new file is in place, and restored
5217    // if the swap fails, so a working file is never lost.
5218    let staged = target.with_extension("old.tmp");
5219    if move_replace_existing(target, &staged).is_err() {
5220        return Err(to_err(last));
5221    }
5222    match move_replace_existing(source, target) {
5223        Ok(()) => {
5224            let _ = fs::remove_file(&staged);
5225            Ok(())
5226        }
5227        Err(error) => {
5228            let _ = move_replace_existing(&staged, target);
5229            Err(to_err(error))
5230        }
5231    }
5232}
5233
5234#[cfg(not(windows))]
5235fn replace_file(source: &Path, target: &Path) -> Result<()> {
5236    fs::rename(source, target)
5237        .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
5238}
5239
5240fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
5241    cache_root(app_root)
5242        .join(bucket)
5243        .join(sanitize_path_segment(identifier))
5244}
5245
5246fn load_store(app_root: &Path) -> Result<SourceStore> {
5247    if uses_packaged_layout(app_root)
5248        && let Some(store) = load_packaged_store(app_root)?
5249    {
5250        return Ok(store);
5251    }
5252
5253    let path = store_path(app_root);
5254    if !path.exists() {
5255        let store = SourceStore::default();
5256        save_store(app_root, &store)?;
5257        return Ok(store);
5258    }
5259
5260    let bytes = fs::read(path).context("failed to read source store")?;
5261    serde_json::from_slice(&bytes).context("failed to parse source store")
5262}
5263
5264fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> {
5265    if uses_system_winget_source_commands(app_root) {
5266        return Ok((
5267            EffectiveSourceMode::SystemWingetDirect,
5268            load_system_winget_source_store()?,
5269        ));
5270    }
5271
5272    match source_mode {
5273        SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5274        SourceMode::SystemWingetMirror => {
5275            let store = load_or_refresh_system_winget_mirror_store(app_root, false)?;
5276            Ok((EffectiveSourceMode::SystemWingetMirror, store))
5277        }
5278        SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) {
5279            Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)),
5280            Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5281        },
5282    }
5283}
5284
5285fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf {
5286    app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME)
5287}
5288
5289fn load_system_winget_mirror_store(app_root: &Path) -> Result<Option<SourceStore>> {
5290    let path = system_winget_mirror_store_path(app_root);
5291    if !path.exists() {
5292        return Ok(None);
5293    }
5294
5295    let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?;
5296    serde_json::from_slice(&bytes)
5297        .context("failed to parse system WinGet mirror source store")
5298        .map(Some)
5299}
5300
5301fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5302    write_json(system_winget_mirror_store_path(app_root), store)
5303}
5304
5305fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result<SourceStore> {
5306    if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? {
5307        return Ok(store);
5308    }
5309
5310    refresh_system_winget_mirror_store(app_root)
5311}
5312
5313fn refresh_system_winget_mirror_store(app_root: &Path) -> Result<SourceStore> {
5314    let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default();
5315    let mut exported = load_system_winget_source_store()?;
5316    merge_source_cache_metadata(&mut exported, &prior);
5317    save_system_winget_mirror_store(app_root, &exported)?;
5318    Ok(exported)
5319}
5320
5321fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) {
5322    let prior_by_identifier: HashMap<_, _> = prior
5323        .sources
5324        .iter()
5325        .map(|source| (source.identifier.to_ascii_lowercase(), source))
5326        .collect();
5327
5328    for source in &mut target.sources {
5329        let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else {
5330            continue;
5331        };
5332        source.last_update = previous.last_update;
5333        source.source_version = previous.source_version.clone();
5334        source.etag = previous.etag.clone();
5335        source.last_modified = previous.last_modified.clone();
5336    }
5337}
5338
5339fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5340    if uses_system_winget_source_commands(app_root) {
5341        bail!("System WinGet source settings must be modified through winget.");
5342    }
5343
5344    if uses_packaged_layout(app_root) {
5345        return save_packaged_store(app_root, store);
5346    }
5347
5348    write_json(store_path(app_root), store)
5349}
5350
5351fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
5352    let user_sources_path = packaged_user_sources_path(app_root)?;
5353    let metadata_path = app_root.join("sources_metadata");
5354    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
5355    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
5356    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
5357}
5358
5359fn load_packaged_store_from_streams(
5360    user_sources_yaml: Option<&str>,
5361    metadata_yaml: Option<&str>,
5362) -> Result<Option<SourceStore>> {
5363    if is_secure_settings_stub(user_sources_yaml) {
5364        return load_system_winget_source_store().map(Some);
5365    }
5366
5367    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
5368}
5369
5370fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5371    let user_sources_path = packaged_user_sources_path(app_root)?;
5372    if let Some(parent) = user_sources_path.parent() {
5373        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
5374    }
5375
5376    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
5377        .context("failed to write packaged user_sources stream")?;
5378    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
5379        .context("failed to write packaged source metadata")?;
5380    Ok(())
5381}
5382
5383fn uses_system_winget_source_commands(app_root: &Path) -> bool {
5384    if !uses_packaged_layout(app_root) {
5385        return false;
5386    }
5387
5388    #[cfg(windows)]
5389    {
5390        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
5391            return false;
5392        };
5393
5394        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
5395        is_secure_settings_stub(user_sources_yaml.as_deref())
5396    }
5397
5398    #[cfg(not(windows))]
5399    {
5400        false
5401    }
5402}
5403
5404fn is_secure_settings_stub(value: Option<&str>) -> bool {
5405    value
5406        .map(str::trim_start)
5407        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
5408}
5409
5410fn load_system_winget_source_store() -> Result<SourceStore> {
5411    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
5412    Ok(SourceStore {
5413        sources: parse_system_winget_source_export(&result.stdout)?,
5414    })
5415}
5416
5417fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
5418    run_checked_winget_source_command(
5419        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
5420        &format!("add WinGet source '{name}'"),
5421    )?;
5422    Ok(())
5423}
5424
5425fn system_winget_remove_source(name: &str) -> Result<()> {
5426    run_checked_winget_source_command(
5427        vec![
5428            "source".to_owned(),
5429            "remove".to_owned(),
5430            "--name".to_owned(),
5431            name.to_owned(),
5432            "--disable-interactivity".to_owned(),
5433        ],
5434        &format!("remove WinGet source '{name}'"),
5435    )?;
5436    Ok(())
5437}
5438
5439fn system_winget_reset_source(name: &str) -> Result<()> {
5440    run_checked_winget_source_command(
5441        vec![
5442            "source".to_owned(),
5443            "reset".to_owned(),
5444            "--name".to_owned(),
5445            name.to_owned(),
5446            "--force".to_owned(),
5447            "--disable-interactivity".to_owned(),
5448        ],
5449        &format!("reset WinGet source '{name}'"),
5450    )?;
5451    Ok(())
5452}
5453
5454fn system_winget_reset_sources() -> Result<()> {
5455    run_checked_winget_source_command(
5456        vec![
5457            "source".to_owned(),
5458            "reset".to_owned(),
5459            "--force".to_owned(),
5460            "--disable-interactivity".to_owned(),
5461        ],
5462        "reset WinGet sources",
5463    )?;
5464    Ok(())
5465}
5466
5467fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
5468    let mut args = vec!["source".to_owned(), "update".to_owned()];
5469    if let Some(name) = name {
5470        args.push("--name".to_owned());
5471        args.push(name.to_owned());
5472    }
5473
5474    args.push("--disable-interactivity".to_owned());
5475    let result = run_checked_winget_source_command(
5476        args,
5477        if name.is_some() {
5478            "update WinGet source"
5479        } else {
5480            "update WinGet sources"
5481        },
5482    )?;
5483    let detail = result.stdout.trim();
5484    Ok(if detail.is_empty() {
5485        "Done".to_owned()
5486    } else {
5487        detail.to_owned()
5488    })
5489}
5490
5491fn build_winget_source_export_arguments() -> Vec<String> {
5492    vec![
5493        "source".to_owned(),
5494        "export".to_owned(),
5495        "--disable-interactivity".to_owned(),
5496    ]
5497}
5498
5499fn build_winget_source_add_arguments(
5500    name: &str,
5501    arg: &str,
5502    kind: SourceKind,
5503    trust_level: &str,
5504    explicit: bool,
5505) -> Vec<String> {
5506    let mut args = vec![
5507        "source".to_owned(),
5508        "add".to_owned(),
5509        "--name".to_owned(),
5510        name.to_owned(),
5511        "--arg".to_owned(),
5512        arg.to_owned(),
5513        "--type".to_owned(),
5514        packaged_source_type(kind).to_owned(),
5515        "--disable-interactivity".to_owned(),
5516    ];
5517
5518    if !trust_level.trim().is_empty() {
5519        args.push("--trust-level".to_owned());
5520        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
5521            "trusted".to_owned()
5522        } else {
5523            "none".to_owned()
5524        });
5525    }
5526
5527    if explicit {
5528        args.push("--explicit".to_owned());
5529    }
5530
5531    args
5532}
5533
5534#[derive(Debug)]
5535struct WingetSourceCommandResult {
5536    exit_code: i32,
5537    stdout: String,
5538    stderr: String,
5539}
5540
5541type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
5542
5543static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
5544    RwLock::new(run_winget_source_command_process);
5545
5546fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
5547    let result = run_winget_source_command(&args)?;
5548    if result.exit_code == 0 {
5549        return Ok(result);
5550    }
5551
5552    let detail = [result.stderr.trim(), result.stdout.trim()]
5553        .into_iter()
5554        .filter(|value| !value.is_empty())
5555        .collect::<Vec<_>>()
5556        .join("\n");
5557    if detail.is_empty() {
5558        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
5559    }
5560
5561    bail!(
5562        "Failed to {action}; winget exited with code {}:\n{}",
5563        result.exit_code,
5564        detail
5565    );
5566}
5567
5568fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
5569    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
5570        .read()
5571        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
5572    runner(args)
5573}
5574
5575fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
5576    use std::process::Command;
5577
5578    let output = Command::new("winget")
5579        .args(args)
5580        .output()
5581        .context("failed to run winget source command")?;
5582
5583    Ok(WingetSourceCommandResult {
5584        exit_code: output.status.code().unwrap_or(-1),
5585        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5586        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5587    })
5588}
5589
5590fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
5591    let trimmed = output.trim();
5592    if trimmed.is_empty() {
5593        return Ok(Vec::new());
5594    }
5595
5596    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
5597        && let Some(sources) = parse_system_winget_source_export_value(&value)?
5598    {
5599        return Ok(sources);
5600    }
5601
5602    let mut sources = Vec::new();
5603    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
5604        if line.is_empty() {
5605            continue;
5606        }
5607
5608        let value: JsonValue = serde_json::from_str(line)
5609            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
5610        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
5611            bail!("WinGet source export returned a non-source line: {line}");
5612        };
5613        sources.append(&mut parsed);
5614    }
5615
5616    Ok(sources)
5617}
5618
5619fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
5620    match value {
5621        JsonValue::Array(items) => items
5622            .iter()
5623            .map(parse_system_winget_source_record)
5624            .collect::<Result<Vec<_>>>()
5625            .map(Some),
5626        JsonValue::Object(object) => {
5627            if let Some(JsonValue::Array(items)) = object.get("Sources") {
5628                items
5629                    .iter()
5630                    .map(parse_system_winget_source_record)
5631                    .collect::<Result<Vec<_>>>()
5632                    .map(Some)
5633            } else if object.contains_key("Name") {
5634                Ok(Some(vec![parse_system_winget_source_record(value)?]))
5635            } else {
5636                Ok(None)
5637            }
5638        }
5639        _ => Ok(None),
5640    }
5641}
5642
5643fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
5644    let object = value
5645        .as_object()
5646        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
5647    let name = required_json_string(object, "Name")?;
5648    let source_type = required_json_string(object, "Type")?;
5649    let arg = required_json_string(object, "Arg")?;
5650    let kind = parse_system_winget_source_kind(&source_type)?;
5651    let identifier = optional_json_string(object, "Identifier")
5652        .or_else(|| optional_json_string(object, "Data"))
5653        .unwrap_or_else(|| name.clone());
5654
5655    Ok(SourceRecord {
5656        name,
5657        kind,
5658        arg,
5659        identifier,
5660        trust_level: parse_system_winget_trust_level(object),
5661        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
5662        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
5663        last_update: None,
5664        source_version: None,
5665        etag: None,
5666        last_modified: None,
5667    })
5668}
5669
5670fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
5671    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
5672}
5673
5674fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
5675    match object.get(key)? {
5676        JsonValue::String(value) => Some(value.clone()),
5677        JsonValue::Number(value) => Some(value.to_string()),
5678        _ => None,
5679    }
5680}
5681
5682fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
5683    match object.get(key)? {
5684        JsonValue::Bool(value) => Some(*value),
5685        JsonValue::String(value) => value.parse().ok(),
5686        _ => None,
5687    }
5688}
5689
5690fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
5691    match object.get(key)? {
5692        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
5693        JsonValue::String(value) => value.parse().ok(),
5694        _ => None,
5695    }
5696}
5697
5698fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
5699    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5700        Ok(SourceKind::PreIndexed)
5701    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5702        Ok(SourceKind::Rest)
5703    } else {
5704        bail!("Unsupported WinGet source type '{value}'.")
5705    }
5706}
5707
5708fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
5709    let Some(value) = object.get("TrustLevel") else {
5710        return "None".to_owned();
5711    };
5712
5713    match value {
5714        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
5715        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
5716        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
5717        _ => "None".to_owned(),
5718    }
5719}
5720
5721fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
5722    value
5723        .as_str()
5724        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
5725}
5726
5727fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
5728    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
5729        return None;
5730    }
5731
5732    let mut sources = SourceStore::default().sources;
5733    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
5734        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5735            continue;
5736        };
5737
5738        if yaml_bool(entry.get("IsTombstone")) {
5739            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
5740            continue;
5741        }
5742
5743        let Some(mapped) = map_packaged_source_entry(&entry) else {
5744            continue;
5745        };
5746
5747        if let Some(existing) = sources
5748            .iter_mut()
5749            .find(|source| source.name.eq_ignore_ascii_case(&name))
5750        {
5751            *existing = mapped;
5752        } else {
5753            sources.push(mapped);
5754        }
5755    }
5756
5757    for entry in parse_packaged_yaml_entries(metadata_yaml) {
5758        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5759            continue;
5760        };
5761
5762        let Some(source) = sources
5763            .iter_mut()
5764            .find(|source| source.name.eq_ignore_ascii_case(&name))
5765        else {
5766            continue;
5767        };
5768
5769        if let Some(last_update) = entry
5770            .get("LastUpdate")
5771            .and_then(|value| value.as_deref())
5772            .and_then(parse_packaged_datetime)
5773        {
5774            source.last_update = Some(last_update);
5775        }
5776
5777        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
5778            && !source_version.is_empty()
5779        {
5780            source.source_version = Some(source_version);
5781        }
5782
5783        if let Some(etag) = entry.get("ETag").and_then(|value| value.clone())
5784            && !etag.is_empty()
5785        {
5786            source.etag = Some(etag);
5787        }
5788
5789        if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone())
5790            && !last_modified.is_empty()
5791        {
5792            source.last_modified = Some(last_modified);
5793        }
5794    }
5795
5796    Some(SourceStore { sources })
5797}
5798
5799fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
5800    let Some(yaml) = yaml else {
5801        return Vec::new();
5802    };
5803
5804    let mut entries = Vec::new();
5805    let mut current: Option<BTreeMap<String, Option<String>>> = None;
5806    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
5807        let line = raw_line.trim_end();
5808        let trimmed = line.trim();
5809        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
5810            continue;
5811        }
5812
5813        if let Some(remainder) = trimmed.strip_prefix("- ") {
5814            if let Some(entry) = current.take() {
5815                entries.push(entry);
5816            }
5817
5818            let mut entry = BTreeMap::new();
5819            add_packaged_yaml_key_value(&mut entry, remainder);
5820            current = Some(entry);
5821            continue;
5822        }
5823
5824        if let Some(entry) = current.as_mut() {
5825            add_packaged_yaml_key_value(entry, trimmed);
5826        }
5827    }
5828
5829    if let Some(entry) = current {
5830        entries.push(entry);
5831    }
5832
5833    entries
5834}
5835
5836fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
5837    let Some((key, value)) = line.split_once(':') else {
5838        return;
5839    };
5840
5841    entry.insert(
5842        key.trim().to_owned(),
5843        Some(unquote_yaml_scalar(value.trim()).to_owned()),
5844    );
5845}
5846
5847fn unquote_yaml_scalar(value: &str) -> &str {
5848    if value.len() >= 2 {
5849        let bytes = value.as_bytes();
5850        let first = bytes[0] as char;
5851        let last = bytes[value.len() - 1] as char;
5852        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
5853            return &value[1..value.len() - 1];
5854        }
5855    }
5856
5857    value
5858}
5859
5860fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
5861    let name = entry.get("Name").and_then(|value| value.clone())?;
5862    let source_type = entry.get("Type").and_then(|value| value.clone())?;
5863    let arg = entry.get("Arg").and_then(|value| value.clone())?;
5864    let kind = parse_packaged_source_kind(&source_type)?;
5865
5866    Some(SourceRecord {
5867        name: name.clone(),
5868        kind,
5869        arg,
5870        identifier: entry
5871            .get("Identifier")
5872            .and_then(|value| value.clone())
5873            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
5874            .unwrap_or(name),
5875        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
5876        explicit: yaml_bool(entry.get("Explicit")),
5877        priority: entry
5878            .get("Priority")
5879            .and_then(|value| value.as_deref())
5880            .and_then(|value| value.parse::<i32>().ok())
5881            .unwrap_or_default(),
5882        last_update: None,
5883        source_version: None,
5884        etag: None,
5885        last_modified: None,
5886    })
5887}
5888
5889fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
5890    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5891        Some(SourceKind::PreIndexed)
5892    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5893        Some(SourceKind::Rest)
5894    } else {
5895        None
5896    }
5897}
5898
5899fn parse_packaged_trust_level(value: Option<&str>) -> String {
5900    match value {
5901        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5902        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5903        _ => "None".to_owned(),
5904    }
5905}
5906
5907fn yaml_bool(value: Option<&Option<String>>) -> bool {
5908    value
5909        .and_then(|raw| raw.as_deref())
5910        .and_then(|raw| raw.parse::<bool>().ok())
5911        .unwrap_or(false)
5912}
5913
5914fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5915    if let Ok(unix_seconds) = value.parse::<i64>() {
5916        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5917    }
5918
5919    DateTime::parse_from_rfc3339(value)
5920        .ok()
5921        .map(|value| value.with_timezone(&Utc))
5922}
5923
5924fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5925    let mut lines = vec!["Sources:".to_owned()];
5926    for source in &store.sources {
5927        lines.push(format!("  - Name: {}", source.name));
5928        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
5929        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
5930        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
5931        lines.push(format!(
5932            "    TrustLevel: {}",
5933            if source.trust_level.eq_ignore_ascii_case("Trusted") {
5934                1
5935            } else {
5936                0
5937            }
5938        ));
5939        lines.push(format!("    Explicit: {}", source.explicit));
5940        lines.push(format!("    Priority: {}", source.priority));
5941        lines.push("    IsTombstone: false".to_owned());
5942    }
5943    lines.push(String::new());
5944    lines.join("\n")
5945}
5946
5947fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5948    let mut lines = vec!["Sources:".to_owned()];
5949    for source in &store.sources {
5950        if source.last_update.is_none()
5951            && source.source_version.is_none()
5952            && source.etag.is_none()
5953            && source.last_modified.is_none()
5954        {
5955            continue;
5956        }
5957
5958        lines.push(format!("  - Name: {}", source.name));
5959        if let Some(last_update) = source.last_update {
5960            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
5961        }
5962        if let Some(source_version) = &source.source_version {
5963            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
5964        }
5965        if let Some(etag) = &source.etag {
5966            lines.push(format!("    ETag: {}", yaml_scalar(etag)));
5967        }
5968        if let Some(last_modified) = &source.last_modified {
5969            lines.push(format!("    LastModified: {}", yaml_scalar(last_modified)));
5970        }
5971    }
5972    lines.push(String::new());
5973    lines.join("\n")
5974}
5975
5976fn yaml_scalar(value: &str) -> String {
5977    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
5978        format!("\"{}\"", value.replace('"', "\\\""))
5979    } else {
5980        value.to_owned()
5981    }
5982}
5983
5984fn uses_packaged_layout(app_root: &Path) -> bool {
5985    #[cfg(windows)]
5986    {
5987        try_get_packaged_local_state_root(app_root).is_some()
5988    }
5989
5990    #[cfg(not(windows))]
5991    {
5992        let _ = app_root;
5993        false
5994    }
5995}
5996
5997#[cfg(windows)]
5998fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
5999    let local_state = app_root.file_name()?.to_string_lossy();
6000    if !local_state.eq_ignore_ascii_case("LocalState") {
6001        return None;
6002    }
6003
6004    let family = app_root.parent()?.file_name()?.to_string_lossy();
6005    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
6006        return None;
6007    }
6008
6009    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
6010    if !packages.eq_ignore_ascii_case("Packages") {
6011        return None;
6012    }
6013
6014    Some(app_root.to_path_buf())
6015}
6016
6017#[cfg(windows)]
6018fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
6019    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
6020    Ok(packaged_secure_settings_root()?.join("user_sources"))
6021}
6022
6023#[cfg(not(windows))]
6024fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
6025    bail!("packaged WinGet source streams are only available on Windows")
6026}
6027
6028#[cfg(windows)]
6029fn packaged_secure_settings_root() -> Result<PathBuf> {
6030    let program_data = std::env::var_os("PROGRAMDATA")
6031        .map(PathBuf::from)
6032        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
6033    Ok(program_data
6034        .join("Microsoft")
6035        .join("WinGet")
6036        .join(current_user_sid()?)
6037        .join("settings")
6038        .join("pkg")
6039        .join(PACKAGED_NAME))
6040}
6041
6042#[cfg(windows)]
6043#[allow(clippy::multiple_unsafe_ops_per_block)]
6044fn current_user_sid() -> Result<String> {
6045    fn close_token(token: HANDLE) {
6046        if !token.is_null() {
6047            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
6048            unsafe {
6049                CloseHandle(token);
6050            }
6051        }
6052    }
6053
6054    let mut token: HANDLE = std::ptr::null_mut();
6055    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
6056    let current_process = unsafe { GetCurrentProcess() };
6057    // SAFETY: token points to valid storage for the process token handle on success.
6058    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
6059        bail!("failed to open current process token")
6060    }
6061
6062    let mut length = 0;
6063    // SAFETY: The null buffer call asks Windows for the required buffer length.
6064    unsafe {
6065        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
6066    }
6067
6068    let element_size = size_of::<TOKEN_USER>();
6069    let element_count = (length as usize).div_ceil(element_size);
6070    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
6071    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
6072    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
6073        close_token(token);
6074        bail!("failed to get current user token information")
6075    }
6076
6077    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
6078    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
6079    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
6080    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
6081    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
6082        close_token(token);
6083        bail!("failed to convert current user SID")
6084    }
6085
6086    let mut len = 0usize;
6087    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
6088    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
6089        len += 1;
6090    }
6091
6092    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
6093    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
6094    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
6095    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
6096    unsafe {
6097        LocalFree(sid_ptr.cast());
6098    }
6099    close_token(token);
6100    Ok(sid)
6101}
6102
6103fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
6104    app_root.join("Microsoft").join("Windows Package Manager")
6105}
6106
6107fn cache_root(app_root: &Path) -> PathBuf {
6108    if uses_packaged_layout(app_root) {
6109        packaged_file_cache_root(app_root)
6110    } else {
6111        default_cache_root_fallback()
6112    }
6113}
6114
6115fn default_cache_root_fallback() -> PathBuf {
6116    std::env::temp_dir().join("cache")
6117}
6118
6119fn packaged_source_type(kind: SourceKind) -> &'static str {
6120    match kind {
6121        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
6122        SourceKind::Rest => "Microsoft.Rest",
6123    }
6124}
6125
6126fn sanitize_path_segment(value: &str) -> String {
6127    value
6128        .chars()
6129        .map(|ch| match ch {
6130            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
6131            _ => ch,
6132        })
6133        .collect()
6134}
6135
6136fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
6137    if !path.exists() {
6138        return Ok(JsonMap::new());
6139    }
6140
6141    let bytes = fs::read(path).context("failed to read JSON file")?;
6142    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
6143    Ok(match value {
6144        JsonValue::Object(object) => object,
6145        _ => JsonMap::new(),
6146    })
6147}
6148
6149fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
6150    write_json(path, value)
6151}
6152
6153fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
6154    if let Some(parent) = path.parent() {
6155        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
6156    }
6157
6158    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
6159    fs::write(path, bytes).context("failed to write JSON file")
6160}
6161
6162fn merge_json_objects(
6163    current: &JsonMap<String, JsonValue>,
6164    update: &JsonMap<String, JsonValue>,
6165) -> JsonMap<String, JsonValue> {
6166    let mut merged = current.clone();
6167    for (key, value) in update {
6168        match (merged.get(key), value) {
6169            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
6170                merged.insert(
6171                    key.clone(),
6172                    JsonValue::Object(merge_json_objects(current_object, update_object)),
6173                );
6174            }
6175            _ => {
6176                merged.insert(key.clone(), value.clone());
6177            }
6178        }
6179    }
6180
6181    merged
6182}
6183
6184fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
6185    match expected {
6186        JsonValue::Object(expected_object) => match current {
6187            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
6188                current_object
6189                    .get(key)
6190                    .is_some_and(|current_child| json_contains(current_child, expected_child))
6191            }),
6192            _ => false,
6193        },
6194        JsonValue::Array(expected_array) => match current {
6195            JsonValue::Array(current_array) => {
6196                current_array.len() == expected_array.len()
6197                    && current_array
6198                        .iter()
6199                        .zip(expected_array.iter())
6200                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
6201            }
6202            _ => false,
6203        },
6204        _ => current == expected,
6205    }
6206}
6207
6208fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
6209    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
6210        "none" | "default" => Ok("None".to_owned()),
6211        "trusted" => Ok("Trusted".to_owned()),
6212        other => bail!("unsupported source trust level: {other}"),
6213    }
6214}
6215
6216fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
6217    SUPPORTED_ADMIN_SETTINGS
6218        .iter()
6219        .copied()
6220        .find(|setting| setting.eq_ignore_ascii_case(name))
6221        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
6222}
6223
6224fn default_source_trust_level() -> String {
6225    "None".to_owned()
6226}
6227
6228fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
6229where
6230    F: FnMut(&SqlRow) -> Result<T>,
6231{
6232    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6233        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6234    let mut result = Vec::new();
6235
6236    while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
6237        result.push(map(&row)?);
6238    }
6239
6240    Ok(result)
6241}
6242
6243fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
6244where
6245    F: FnOnce(&SqlRow) -> Result<T>,
6246{
6247    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6248        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6249
6250    match pollster::block_on(rows.next()).context("failed to read SQL row")? {
6251        Some(row) => Ok(Some(map(&row)?)),
6252        None => Ok(None),
6253    }
6254}
6255
6256fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
6257    pollster::block_on(connection.execute(sql, params_from_iter(params)))
6258        .with_context(|| format!("failed to execute SQL statement: {sql}"))
6259}
6260
6261fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
6262    pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
6263}
6264
6265fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
6266    row.get_value(index)
6267        .with_context(|| format!("failed to read SQL column {index}"))
6268}
6269
6270fn row_string(row: &SqlRow, index: usize) -> Result<String> {
6271    match row_value(row, index)? {
6272        SqlValue::Text(value) => Ok(value),
6273        SqlValue::Integer(value) => Ok(value.to_string()),
6274        SqlValue::Real(value) => Ok(value.to_string()),
6275        SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
6276        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6277    }
6278}
6279
6280fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6281    match row_value(row, index)? {
6282        SqlValue::Null => Ok(None),
6283        SqlValue::Text(value) => Ok(Some(value)),
6284        SqlValue::Integer(value) => Ok(Some(value.to_string())),
6285        SqlValue::Real(value) => Ok(Some(value.to_string())),
6286        SqlValue::Blob(value) => Ok(Some(
6287            String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
6288        )),
6289    }
6290}
6291
6292fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
6293    match row_value(row, index)? {
6294        SqlValue::Integer(value) => Ok(value),
6295        SqlValue::Text(value) => value
6296            .parse::<i64>()
6297            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
6298        SqlValue::Real(value) => f64_to_i64_checked(value, index),
6299        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6300        SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
6301    }
6302}
6303
6304fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
6305    match row_value(row, index)? {
6306        SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
6307        SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
6308        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6309        SqlValue::Integer(value) => Ok(format!("{value:x}")),
6310        SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
6311    }
6312}
6313
6314fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6315    match row_value(row, index)? {
6316        SqlValue::Null => Ok(None),
6317        SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
6318        SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
6319        SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
6320        SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
6321    }
6322}
6323
6324fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
6325    if !value.is_finite() {
6326        bail!("SQL column {index} contained a non-finite floating-point value");
6327    }
6328
6329    if value.fract() != 0.0 {
6330        bail!("SQL column {index} contained a non-integer floating-point value");
6331    }
6332
6333    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
6334        bail!("SQL column {index} contained a floating-point value outside the i64 range");
6335    }
6336
6337    format!("{value:.0}")
6338        .parse::<i64>()
6339        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
6340}
6341
6342fn bytes_to_hex(bytes: &[u8]) -> String {
6343    let mut result = String::with_capacity(bytes.len() * 2);
6344    for byte in bytes {
6345        result.push_str(&format!("{byte:02x}"));
6346    }
6347    result
6348}
6349
6350fn query_v2_matches(
6351    connection: &Connection,
6352    query: &PackageQuery,
6353    semantics: SearchSemantics,
6354) -> Result<(Vec<V2SearchRow>, bool)> {
6355    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
6356    let limit = source_fetch_results(query, semantics);
6357    let sql = format!(
6358        "SELECT rowid, id, name, moniker, latest_version, hash \
6359         FROM packages WHERE {where_clause} LIMIT {}",
6360        limit + 1
6361    );
6362    let mut rows = query_rows(
6363        connection,
6364        &sql,
6365        params.into_iter().map(SqlValue::Text).collect(),
6366        |row| {
6367            Ok(V2SearchRow {
6368                package_rowid: row_i64(row, 0)?,
6369                id: row_string(row, 1)?,
6370                name: row_string(row, 2)?,
6371                moniker: row_opt_string(row, 3)?,
6372                version: row_string(row, 4)?,
6373                package_hash: row_hex_string(row, 5)?,
6374                match_criteria: None,
6375            })
6376        },
6377    )?;
6378    for row in &mut rows {
6379        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
6380    }
6381    let truncated = rows.len() > limit;
6382    rows.truncate(limit);
6383    Ok((rows, truncated))
6384}
6385
6386fn query_v1_matches(
6387    connection: &Connection,
6388    query: &PackageQuery,
6389    semantics: SearchSemantics,
6390) -> Result<(Vec<V1SearchRow>, bool)> {
6391    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
6392    let limit = source_fetch_results(query, semantics);
6393    let sql = format!(
6394        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
6395         FROM manifest \
6396         JOIN ids ON manifest.id = ids.rowid \
6397         JOIN names ON manifest.name = names.rowid \
6398         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
6399         JOIN versions ON manifest.version = versions.rowid \
6400         JOIN channels ON manifest.channel = channels.rowid \
6401         WHERE {where_clause} LIMIT {}",
6402        limit + 1
6403    );
6404    let mut rows = query_rows(
6405        connection,
6406        &sql,
6407        params.into_iter().map(SqlValue::Text).collect(),
6408        |row| {
6409            Ok(V1SearchRow {
6410                manifest_rowid: row_i64(row, 0)?,
6411                package_rowid: row_i64(row, 1)?,
6412                version: row_string(row, 2)?,
6413                channel: row_string(row, 3)?,
6414                id: row_string(row, 4)?,
6415                name: row_string(row, 5)?,
6416                moniker: row_opt_string(row, 6)?,
6417                match_criteria: None,
6418            })
6419        },
6420    )?;
6421    for row in &mut rows {
6422        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
6423    }
6424    let truncated = rows.len() > limit;
6425    rows.truncate(limit);
6426    Ok((rows, truncated))
6427}
6428
6429fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
6430    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
6431
6432    for row in rows {
6433        match grouped.get(&row.package_rowid) {
6434            Some(existing)
6435                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
6436                    != Ordering::Less => {}
6437            _ => {
6438                grouped.insert(row.package_rowid, row);
6439            }
6440        }
6441    }
6442
6443    grouped.into_values().collect()
6444}
6445
6446fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
6447    let has_hash = table_has_column(connection, "manifest", "hash")?;
6448    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
6449    let sql = format!(
6450        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
6451         FROM manifest \
6452         JOIN versions ON manifest.version = versions.rowid \
6453         JOIN channels ON manifest.channel = channels.rowid \
6454         WHERE manifest.id = ?1"
6455    );
6456    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
6457        Ok(V1VersionRow {
6458            version: row_string(row, 0)?,
6459            channel: row_string(row, 1)?,
6460            pathpart_id: row_i64(row, 2)?,
6461            manifest_hash: row_opt_hex_string(row, 3)?,
6462        })
6463    })
6464}
6465
6466fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
6467    let mut parts = Vec::new();
6468    let mut current = Some(pathpart_id);
6469
6470    while let Some(id) = current {
6471        let (parent, pathpart) = query_optional_value(
6472            connection,
6473            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
6474            vec![SqlValue::Integer(id)],
6475            |row| {
6476                Ok((
6477                    row_opt_string(row, 0)?
6478                        .and_then(|value| value.parse::<i64>().ok())
6479                        .filter(|value| *value != 0),
6480                    row_string(row, 1)?,
6481                ))
6482            },
6483        )?
6484        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
6485        parts.push(pathpart);
6486        current = parent;
6487    }
6488
6489    parts.reverse();
6490    Ok(parts.join("/"))
6491}
6492
6493fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
6494    format!(
6495        "packages/{}/{}/versionData.mszyml",
6496        package_id,
6497        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
6498    )
6499}
6500
6501fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
6502    let message = format!("{error:#}").to_ascii_lowercase();
6503    message.contains("no such table") && message.contains("packages")
6504}
6505
6506fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
6507    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
6508        row_string(row, 1)
6509    })? {
6510        if value.eq_ignore_ascii_case(column) {
6511            return Ok(true);
6512        }
6513    }
6514
6515    Ok(false)
6516}
6517
6518fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
6519    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
6520    let id_column = if v2 { "id" } else { "ids.id" };
6521    let name_column = if v2 { "name" } else { "names.name" };
6522    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
6523    let exact_match = query.exact || semantics == SearchSemantics::Single;
6524    let mut params = Vec::new();
6525
6526    if let Some(value) = &query.id {
6527        return (
6528            single_field_condition(id_column, value, exact_match, &mut params),
6529            params,
6530        );
6531    }
6532    if let Some(value) = &query.name {
6533        return (
6534            single_field_condition(name_column, value, exact_match, &mut params),
6535            params,
6536        );
6537    }
6538    if let Some(value) = &query.moniker {
6539        return (
6540            single_field_condition(moniker_column, value, exact_match, &mut params),
6541            params,
6542        );
6543    }
6544    if let Some(value) = &query.tag {
6545        return (
6546            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
6547            params,
6548        );
6549    }
6550    if let Some(value) = &query.command {
6551        return (
6552            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
6553            params,
6554        );
6555    }
6556    if let Some(value) = &query.query {
6557        if exact_match {
6558            let conditions = [
6559                single_field_condition(id_column, value, true, &mut params),
6560                single_field_condition(name_column, value, true, &mut params),
6561                single_field_condition(moniker_column, value, true, &mut params),
6562            ];
6563            return (format!("({})", conditions.join(" OR ")), params);
6564        }
6565
6566        let mut conditions = vec![
6567            single_field_condition(id_column, value, false, &mut params),
6568            single_field_condition(name_column, value, false, &mut params),
6569            single_field_condition(moniker_column, value, false, &mut params),
6570        ];
6571        if semantics == SearchSemantics::Many {
6572            conditions.push(mapped_field_condition(
6573                v2,
6574                "tag",
6575                value,
6576                rowid_column,
6577                false,
6578                &mut params,
6579            ));
6580            conditions.push(mapped_field_condition(
6581                v2,
6582                "command",
6583                value,
6584                rowid_column,
6585                false,
6586                &mut params,
6587            ));
6588        }
6589        return (format!("({})", conditions.join(" OR ")), params);
6590    }
6591
6592    ("1 = 1".to_owned(), Vec::new())
6593}
6594
6595fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
6596    params.push(match_parameter(value, exact));
6597    format!("{column} LIKE ?{}", params.len())
6598}
6599
6600fn mapped_field_condition(
6601    v2: bool,
6602    value_name: &str,
6603    value: &str,
6604    rowid_column: &str,
6605    exact: bool,
6606    params: &mut Vec<String>,
6607) -> String {
6608    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
6609        (
6610            format!("{value_name}s2"),
6611            format!("{value_name}s2_map"),
6612            value_name.to_owned(),
6613            "package".to_owned(),
6614        )
6615    } else {
6616        (
6617            format!("{value_name}s"),
6618            format!("{value_name}s_map"),
6619            value_name.to_owned(),
6620            "manifest".to_owned(),
6621        )
6622    };
6623    params.push(match_parameter(value, exact));
6624    let parameter = params.len();
6625    // Use a non-correlated `rowid IN (...)` subquery rather than a correlated
6626    // `EXISTS (... WHERE map.owner = packages.rowid ...)`. The correlated form
6627    // re-scans the (un-indexed) map table once per candidate package row, which
6628    // is quadratic: on the real winget index it takes ~19s under SQLite and
6629    // effectively hangs under the Turso engine. The `IN` form evaluates the
6630    // owner-id set a single time, collapsing the search back to milliseconds.
6631    format!(
6632        "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
6633         FROM {map_table_name} JOIN {table_name} ON \
6634         {map_table_name}.{value_name} = {table_name}.rowid \
6635         WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
6636    )
6637}
6638
6639fn match_parameter(value: &str, exact: bool) -> String {
6640    if exact { value.to_owned() } else { format!("%{value}%") }
6641}
6642
6643fn build_rest_search_body(
6644    query: &PackageQuery,
6645    info: &RestInformation,
6646    semantics: SearchSemantics,
6647) -> Result<JsonValue> {
6648    let mut root = serde_json::Map::new();
6649    root.insert(
6650        "MaximumResults".to_owned(),
6651        JsonValue::from(source_fetch_results(query, semantics) as u64),
6652    );
6653    let exact_match = query.exact || semantics == SearchSemantics::Single;
6654
6655    if let Some(value) = &query.query {
6656        if semantics == SearchSemantics::Single {
6657            let mut filters = vec![
6658                rest_filter("PackageIdentifier", value, true),
6659                rest_filter("PackageName", value, true),
6660                rest_filter("Moniker", value, true),
6661            ];
6662            append_required_rest_filters(&mut filters, info);
6663            root.insert("Filters".to_owned(), JsonValue::Array(filters));
6664            return Ok(JsonValue::Object(root));
6665        }
6666
6667        root.insert(
6668            "Query".to_owned(),
6669            serde_json::json!({
6670                "KeyWord": value,
6671                "MatchType": if exact_match { "Exact" } else { "Substring" },
6672            }),
6673        );
6674    }
6675
6676    let mut filters = Vec::new();
6677    if let Some(value) = &query.id {
6678        filters.push(rest_filter("PackageIdentifier", value, exact_match));
6679    }
6680    if let Some(value) = &query.name {
6681        filters.push(rest_filter("PackageName", value, exact_match));
6682    }
6683    if let Some(value) = &query.moniker {
6684        filters.push(rest_filter("Moniker", value, exact_match));
6685    }
6686    if let Some(value) = &query.tag {
6687        filters.push(rest_filter("Tag", value, true));
6688    }
6689    if let Some(value) = &query.command {
6690        filters.push(rest_filter("Command", value, true));
6691    }
6692    append_required_rest_filters(&mut filters, info);
6693
6694    if !filters.is_empty() {
6695        root.insert("Filters".to_owned(), JsonValue::Array(filters));
6696    }
6697
6698    Ok(JsonValue::Object(root))
6699}
6700
6701fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
6702    if info
6703        .required_package_match_fields
6704        .iter()
6705        .any(|field| field.eq_ignore_ascii_case("Market"))
6706    {
6707        filters.push(rest_filter("Market", &default_market(), true));
6708    }
6709}
6710
6711fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
6712    serde_json::json!({
6713        "PackageMatchField": field,
6714        "RequestMatch": {
6715            "KeyWord": value,
6716            "MatchType": if exact { "Exact" } else { "Substring" },
6717        }
6718    })
6719}
6720
6721fn max_results(query: &PackageQuery) -> usize {
6722    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
6723}
6724
6725fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
6726    match semantics {
6727        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
6728        SearchSemantics::Single => max_results(query),
6729    }
6730}
6731
6732fn infer_preindexed_match_criteria_v2(
6733    connection: &Connection,
6734    row: &V2SearchRow,
6735    query: &PackageQuery,
6736    semantics: SearchSemantics,
6737) -> Result<Option<String>> {
6738    infer_match_criteria(
6739        &row.id,
6740        &row.name,
6741        row.moniker.as_deref(),
6742        query,
6743        semantics,
6744        |value| {
6745            find_mapped_value_v2(
6746                connection,
6747                "tags2",
6748                "tags2_map",
6749                "tag",
6750                row.package_rowid,
6751                value,
6752                query.exact,
6753            )
6754        },
6755        |value| {
6756            find_mapped_value_v2(
6757                connection,
6758                "commands2",
6759                "commands2_map",
6760                "command",
6761                row.package_rowid,
6762                value,
6763                query.exact,
6764            )
6765        },
6766    )
6767}
6768
6769fn infer_preindexed_match_criteria_v1(
6770    connection: &Connection,
6771    row: &V1SearchRow,
6772    query: &PackageQuery,
6773    semantics: SearchSemantics,
6774) -> Result<Option<String>> {
6775    infer_match_criteria(
6776        &row.id,
6777        &row.name,
6778        row.moniker.as_deref(),
6779        query,
6780        semantics,
6781        |value| {
6782            find_mapped_value_v1(
6783                connection,
6784                "tags",
6785                "tags_map",
6786                "tag",
6787                row.manifest_rowid,
6788                value,
6789                query.exact,
6790            )
6791        },
6792        |value| {
6793            find_mapped_value_v1(
6794                connection,
6795                "commands",
6796                "commands_map",
6797                "command",
6798                row.manifest_rowid,
6799                value,
6800                query.exact,
6801            )
6802        },
6803    )
6804}
6805
6806fn infer_match_criteria<FTag, FCommand>(
6807    id: &str,
6808    name: &str,
6809    moniker: Option<&str>,
6810    query: &PackageQuery,
6811    semantics: SearchSemantics,
6812    find_tag: FTag,
6813    find_command: FCommand,
6814) -> Result<Option<String>>
6815where
6816    FTag: FnOnce(&str) -> Result<Option<String>>,
6817    FCommand: FnOnce(&str) -> Result<Option<String>>,
6818{
6819    if let Some(value) = &query.tag {
6820        return Ok(Some(format_match_criteria("Tag", value)));
6821    }
6822    if let Some(value) = &query.command {
6823        return Ok(Some(format_match_criteria("Command", value)));
6824    }
6825    if let Some(value) = &query.moniker {
6826        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
6827    }
6828    if semantics == SearchSemantics::Single {
6829        return Ok(None);
6830    }
6831    if let Some(value) = &query.query {
6832        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
6833            return Ok(None);
6834        }
6835        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
6836            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
6837        }
6838        if let Some(tag) = find_tag(value)? {
6839            return Ok(Some(format_match_criteria("Tag", &tag)));
6840        }
6841        if let Some(command) = find_command(value)? {
6842            return Ok(Some(format_match_criteria("Command", &command)));
6843        }
6844    }
6845    Ok(None)
6846}
6847
6848fn find_mapped_value_v2(
6849    connection: &Connection,
6850    table_name: &str,
6851    map_table_name: &str,
6852    value_name: &str,
6853    package_rowid: i64,
6854    query: &str,
6855    exact: bool,
6856) -> Result<Option<String>> {
6857    let sql = format!(
6858        "SELECT {table_name}.{value_name} FROM {map_table_name} \
6859         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6860         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
6861    );
6862    let values = query_rows(
6863        connection,
6864        &sql,
6865        vec![
6866            SqlValue::Integer(package_rowid),
6867            SqlValue::Text(match_parameter(query, exact)),
6868        ],
6869        |row| row_string(row, 0),
6870    )?;
6871    Ok(select_best_text_match(values, query, exact))
6872}
6873
6874fn find_mapped_value_v1(
6875    connection: &Connection,
6876    table_name: &str,
6877    map_table_name: &str,
6878    value_name: &str,
6879    manifest_rowid: i64,
6880    query: &str,
6881    exact: bool,
6882) -> Result<Option<String>> {
6883    let sql = format!(
6884        "SELECT {table_name}.{value_name} FROM {map_table_name} \
6885         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6886         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
6887    );
6888    let values = query_rows(
6889        connection,
6890        &sql,
6891        vec![
6892            SqlValue::Integer(manifest_rowid),
6893            SqlValue::Text(match_parameter(query, exact)),
6894        ],
6895        |row| row_string(row, 0),
6896    )?;
6897    Ok(select_best_text_match(values, query, exact))
6898}
6899
6900fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
6901    if let Some(value) = &query.tag {
6902        return Some(format_match_criteria("Tag", value));
6903    }
6904    if let Some(value) = &query.command {
6905        return Some(format_match_criteria("Command", value));
6906    }
6907    if let Some(value) = &query.moniker {
6908        return Some(format_match_criteria("Moniker", value));
6909    }
6910    if semantics == SearchSemantics::Single {
6911        return None;
6912    }
6913    let value = query.query.as_deref()?;
6914    if json_string(item, "PackageIdentifier")
6915        .as_deref()
6916        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6917        || json_string(item, "PackageName")
6918            .as_deref()
6919            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6920    {
6921        return None;
6922    }
6923    json_string(item, "Moniker")
6924        .filter(|candidate| matches_text(candidate, value, query.exact))
6925        .map(|candidate| format_match_criteria("Moniker", &candidate))
6926}
6927
6928fn format_match_criteria(field: &str, value: &str) -> String {
6929    format!("{field}: {value}")
6930}
6931
6932fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6933    if exact {
6934        candidate.eq_ignore_ascii_case(query)
6935    } else {
6936        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6937    }
6938}
6939
6940fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6941    let mut score = 0;
6942
6943    if let Some(value) = &query.query {
6944        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6945        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6946        if let Some(moniker) = candidate.moniker.as_deref() {
6947            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6948        }
6949    }
6950
6951    if let Some(value) = &query.id {
6952        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6953    }
6954    if let Some(value) = &query.name {
6955        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6956    }
6957    if let Some(value) = &query.moniker
6958        && let Some(moniker) = candidate.moniker.as_deref()
6959    {
6960        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6961    }
6962    if let Some(value) = &query.tag
6963        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6964    {
6965        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6966    }
6967    if let Some(value) = &query.command
6968        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6969    {
6970        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6971    }
6972
6973    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6974        score += 5;
6975    }
6976    if search_match_has_unknown_version(candidate) {
6977        score = score.saturating_sub(10);
6978    }
6979
6980    score
6981}
6982
6983fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
6984    candidate
6985        .version
6986        .as_deref()
6987        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
6988}
6989
6990/// Builds the YAML manifest emitted next to the installer by `winget download`.
6991///
6992/// `documents` is the merged singleton JSON the source provided; we narrow the
6993/// `Installers` array down to just the resolved installer so the file describes
6994/// exactly the binary that was written, mirroring native winget behavior. The
6995/// typed `manifest` overrides identity fields (id, version, name, channel) on
6996/// the JSON because callers like `manifest_for_match` correct those after the
6997/// initial YAML parse.
6998fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
6999    let mut document = match documents {
7000        JsonValue::Object(map) => map.clone(),
7001        _ => serde_json::Map::new(),
7002    };
7003
7004    document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
7005    document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
7006    document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
7007    if !manifest.channel.is_empty() {
7008        document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
7009    } else {
7010        document.remove("Channel");
7011    }
7012
7013    // Replace the multi-installer array with the single resolved installer so
7014    // the emitted manifest describes the file the user actually downloaded.
7015    let mut single_installer = serde_json::Map::new();
7016    if let Some(arch) = installer.architecture.as_ref() {
7017        single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
7018    }
7019    if let Some(installer_type) = installer.installer_type.as_ref() {
7020        single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
7021    }
7022    if let Some(url) = installer.url.as_ref() {
7023        single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
7024    }
7025    if let Some(sha) = installer.sha256.as_ref() {
7026        single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
7027    }
7028    if let Some(scope) = installer.scope.as_ref() {
7029        single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
7030    }
7031    if let Some(locale) = installer.locale.as_ref() {
7032        single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
7033    }
7034    if let Some(product_code) = installer.product_code.as_ref() {
7035        single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
7036    }
7037    if let Some(release_date) = installer.release_date.as_ref() {
7038        single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
7039    }
7040
7041    document.insert(
7042        "Installers".to_owned(),
7043        JsonValue::Array(vec![JsonValue::Object(single_installer)]),
7044    );
7045    document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
7046
7047    let value = JsonValue::Object(document);
7048    serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
7049}
7050
7051/// Computes the file name `winget download` uses for the YAML manifest
7052/// emitted next to the installer:
7053/// `{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml`.
7054/// Mirrors `GetInstallerDownloadOnlyFileName` in winget-cli's DownloadFlow.
7055fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
7056    let mut filename = derive_download_filename_stem(manifest, installer);
7057    filename.push_str(".yaml");
7058    filename
7059}
7060
7061fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
7062    let mut stem = sanitize_filename_component(&manifest.name);
7063    if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
7064        stem.push('_');
7065        stem.push_str(&sanitize_filename_component(&manifest.version));
7066    }
7067    if let Some(scope) = installer.scope.as_deref() {
7068        stem.push('_');
7069        stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
7070    }
7071    let architecture = installer.architecture.as_deref().unwrap_or("neutral");
7072    stem.push('_');
7073    stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
7074
7075    let installer_type = installer.installer_type.as_deref().unwrap_or("");
7076    if !installer_type.is_empty() {
7077        stem.push('_');
7078        stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
7079    }
7080    if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
7081        stem.push('_');
7082        stem.push_str(&sanitize_filename_component(locale));
7083    }
7084
7085    stem
7086}
7087
7088fn capitalize_first(value: &str) -> String {
7089    let mut chars = value.chars();
7090    match chars.next() {
7091        Some(first) => {
7092            let mut out = first.to_ascii_uppercase().to_string();
7093            out.push_str(&chars.as_str().to_ascii_lowercase());
7094            out
7095        }
7096        None => String::new(),
7097    }
7098}
7099
7100fn format_architecture(value: &str) -> String {
7101    match value.to_ascii_lowercase().as_str() {
7102        "x64" => "X64".to_owned(),
7103        "x86" => "X86".to_owned(),
7104        "arm" => "Arm".to_owned(),
7105        "arm64" => "Arm64".to_owned(),
7106        "neutral" => "Neutral".to_owned(),
7107        other => capitalize_first(other),
7108    }
7109}
7110
7111fn sanitize_filename_component(value: &str) -> String {
7112    value
7113        .chars()
7114        .map(|ch| match ch {
7115            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
7116            ch if ch.is_control() => '_',
7117            ch => ch,
7118        })
7119        .collect()
7120}
7121
7122fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
7123    if manifest_path.is_file() {
7124        return Ok(manifest_path.to_path_buf());
7125    }
7126
7127    if !manifest_path.is_dir() {
7128        bail!("Manifest path not found: {}", manifest_path.display());
7129    }
7130
7131    let candidate = fs::read_dir(manifest_path)?
7132        .filter_map(|entry| entry.ok())
7133        .map(|entry| entry.path())
7134        .filter(|path| {
7135            path.extension()
7136                .and_then(|value| value.to_str())
7137                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
7138        })
7139        .min();
7140
7141    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
7142}
7143
7144fn score_text_match(
7145    candidate: &str,
7146    query: &str,
7147    exact: bool,
7148    exact_score: usize,
7149    prefix_score: usize,
7150    substring_score: usize,
7151) -> usize {
7152    if candidate.eq_ignore_ascii_case(query) {
7153        return exact_score;
7154    }
7155    if exact {
7156        return 0;
7157    }
7158
7159    let candidate_lower = candidate.to_ascii_lowercase();
7160    let query_lower = query.to_ascii_lowercase();
7161    if candidate_lower.starts_with(&query_lower) {
7162        prefix_score
7163    } else if candidate_lower.contains(&query_lower) {
7164        substring_score
7165    } else {
7166        0
7167    }
7168}
7169
7170fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
7171where
7172    I: IntoIterator<Item = String>,
7173{
7174    values
7175        .into_iter()
7176        .filter_map(|candidate| {
7177            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
7178            (score > 0).then_some((score, candidate))
7179        })
7180        .max_by(|(left_score, left_value), (right_score, right_value)| {
7181            left_score
7182                .cmp(right_score)
7183                .then_with(|| right_value.len().cmp(&left_value.len()))
7184                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
7185        })
7186        .map(|(_, candidate)| candidate)
7187}
7188
7189fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
7190    let (field, value) = criteria.split_once(": ")?;
7191    Some((field, value))
7192}
7193
7194fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
7195    let versions = item
7196        .get("Versions")
7197        .and_then(JsonValue::as_array)
7198        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
7199    let mut result = Vec::new();
7200    for version in versions {
7201        let value = json_string(version, "PackageVersion")
7202            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
7203        let channel = json_string(version, "Channel").unwrap_or_default();
7204        result.push(VersionKey {
7205            version: value,
7206            channel,
7207        });
7208    }
7209    Ok(result)
7210}
7211
7212fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
7213    let mut merged = YamlMapping::new();
7214    let mut documents = Vec::new();
7215    for document in serde_yaml::Deserializer::from_slice(bytes) {
7216        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
7217        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
7218        if let Some(mapping) = value.as_mapping() {
7219            merge_yaml_mapping(&mut merged, mapping);
7220        }
7221    }
7222
7223    let id = yaml_string_from_root(&merged, "PackageIdentifier")
7224        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
7225    let version =
7226        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
7227    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
7228
7229    let installers = parse_yaml_installers(&merged);
7230    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
7231    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7232
7233    Ok((
7234        Manifest {
7235            id,
7236            name,
7237            version,
7238            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
7239            publisher: yaml_localized_string(&merged, "Publisher"),
7240            description: yaml_localized_string(&merged, "Description")
7241                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
7242            moniker: yaml_string_from_root(&merged, "Moniker"),
7243            package_url: yaml_localized_string(&merged, "PackageUrl"),
7244            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
7245            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
7246            license: yaml_localized_string(&merged, "License"),
7247            license_url: yaml_localized_string(&merged, "LicenseUrl"),
7248            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
7249            author: yaml_localized_string(&merged, "Author"),
7250            copyright: yaml_localized_string(&merged, "Copyright"),
7251            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
7252            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
7253            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
7254            tags: yaml_string_list(&merged, "Tags"),
7255            agreements: yaml_agreement_list(&merged),
7256            package_dependencies: yaml_package_dependencies(&merged),
7257            documentation: yaml_documentation_list(&merged),
7258            icons: yaml_icon_list(&merged),
7259            installers,
7260            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7261        },
7262        collapse_structured_document(&JsonValue::Array(documents)),
7263    ))
7264}
7265
7266fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
7267    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
7268}
7269
7270fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
7271    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
7272    let data = root
7273        .get("Data")
7274        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
7275    let versions = data
7276        .get("Versions")
7277        .and_then(JsonValue::as_array)
7278        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
7279    let selected = versions
7280        .iter()
7281        .find(|item| {
7282            json_string(item, "PackageVersion").as_deref() == Some(version)
7283                && json_string(item, "Channel").unwrap_or_default() == channel
7284        })
7285        .or_else(|| versions.first())
7286        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
7287
7288    let default_locale = selected
7289        .get("DefaultLocale")
7290        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
7291    let package_default_locale = data.get("DefaultLocale");
7292    let name = json_string(default_locale, "PackageName")
7293        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
7294    let installer_switch_defaults = json_installer_switches(selected);
7295    let top_platforms = json_string_list(selected, "Platform");
7296    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
7297    let top_installer_type = json_string(selected, "InstallerType");
7298    let top_nested_installer_type = json_string(selected, "NestedInstallerType");
7299    let top_nested_installer_files = json_nested_installer_files(selected);
7300
7301    let installers = selected
7302        .get("Installers")
7303        .and_then(JsonValue::as_array)
7304        .map(|items| {
7305            items
7306                .iter()
7307                .map(|item| Installer {
7308                    platforms: {
7309                        let platforms = json_string_list(item, "Platform");
7310                        if platforms.is_empty() {
7311                            top_platforms.clone()
7312                        } else {
7313                            platforms
7314                        }
7315                    },
7316                    minimum_os_version: json_string(item, "MinimumOSVersion")
7317                        .or_else(|| top_minimum_os_version.clone()),
7318                    architecture: json_string(item, "Architecture"),
7319                    installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
7320                    nested_installer_type: json_string(item, "NestedInstallerType")
7321                        .or_else(|| top_nested_installer_type.clone()),
7322                    nested_installer_files: {
7323                        let files = json_nested_installer_files(item);
7324                        if files.is_empty() {
7325                            top_nested_installer_files.clone()
7326                        } else {
7327                            files
7328                        }
7329                    },
7330                    url: json_string(item, "InstallerUrl"),
7331                    sha256: json_string(item, "InstallerSha256"),
7332                    product_code: json_string(item, "ProductCode"),
7333                    locale: json_string(item, "InstallerLocale"),
7334                    scope: json_string(item, "Scope"),
7335                    release_date: json_string(item, "ReleaseDate"),
7336                    package_family_name: json_string(item, "PackageFamilyName"),
7337                    upgrade_code: json_string(item, "UpgradeCode"),
7338                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
7339                    commands: json_string_list(item, "Commands"),
7340                    package_dependencies: json_package_dependencies(item),
7341                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
7342                })
7343                .collect::<Vec<_>>()
7344        })
7345        .unwrap_or_default();
7346    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
7347    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7348    let mut icons = json_icon_list(default_locale);
7349    if icons.is_empty() {
7350        icons = package_default_locale.map(json_icon_list).unwrap_or_default();
7351    }
7352
7353    let manifest = Manifest {
7354        id: package_id.to_owned(),
7355        name,
7356        version: version.to_owned(),
7357        channel: channel.to_owned(),
7358        publisher: json_string(default_locale, "Publisher"),
7359        description: json_string(default_locale, "Description")
7360            .or_else(|| json_string(default_locale, "ShortDescription")),
7361        moniker: json_string(default_locale, "Moniker"),
7362        package_url: json_string(default_locale, "PackageUrl"),
7363        publisher_url: json_string(default_locale, "PublisherUrl"),
7364        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
7365        license: json_string(default_locale, "License"),
7366        license_url: json_string(default_locale, "LicenseUrl"),
7367        privacy_url: json_string(default_locale, "PrivacyUrl"),
7368        author: json_string(default_locale, "Author"),
7369        copyright: json_string(default_locale, "Copyright"),
7370        copyright_url: json_string(default_locale, "CopyrightUrl"),
7371        release_notes: json_string(default_locale, "ReleaseNotes"),
7372        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
7373        tags: json_string_list(default_locale, "Tags"),
7374        agreements: json_agreement_list(default_locale),
7375        package_dependencies: json_package_dependencies(selected),
7376        documentation: json_documentation_list(default_locale),
7377        icons,
7378        installers,
7379        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7380    };
7381
7382    Ok((
7383        manifest,
7384        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
7385    ))
7386}
7387
7388fn collapse_structured_document(document: &JsonValue) -> JsonValue {
7389    match document {
7390        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
7391            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
7392        }
7393        JsonValue::Array(documents) => merge_manifest_documents(documents),
7394        JsonValue::Object(_) if is_merged_manifest_document(document) => {
7395            merge_manifest_documents(&split_merged_manifest_document(document))
7396        }
7397        _ => document.clone(),
7398    }
7399}
7400
7401fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
7402    documents.iter().map(collapse_structured_document).collect()
7403}
7404
7405fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
7406    let Some(root) = document.as_object_mut() else {
7407        return document;
7408    };
7409
7410    root.insert(
7411        "IconUrl".to_owned(),
7412        manifest
7413            .icon_url()
7414            .map(|url| JsonValue::String(url.to_owned()))
7415            .unwrap_or(JsonValue::Null),
7416    );
7417    root.insert(
7418        "Icon".to_owned(),
7419        manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
7420    );
7421    root.entry("Icons".to_owned())
7422        .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
7423
7424    document
7425}
7426
7427fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
7428    serde_json::to_value(icon).expect("package icon serialization cannot fail")
7429}
7430
7431fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
7432    let version = documents.iter().find(|document| {
7433        document
7434            .get("ManifestType")
7435            .and_then(JsonValue::as_str)
7436            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
7437    });
7438    let default_locale = documents.iter().find(|document| {
7439        document
7440            .get("ManifestType")
7441            .and_then(JsonValue::as_str)
7442            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
7443    });
7444    let installer = documents.iter().find(|document| {
7445        document
7446            .get("ManifestType")
7447            .and_then(JsonValue::as_str)
7448            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
7449    });
7450
7451    let mut singleton = JsonMap::new();
7452    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
7453    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
7454    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
7455
7456    if !singleton.contains_key("PackageLocale")
7457        && let Some(package_locale) = default_locale
7458            .and_then(|document| json_string(document, "PackageLocale"))
7459            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
7460    {
7461        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7462    }
7463
7464    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
7465    singleton.insert(
7466        "ManifestVersion".to_owned(),
7467        JsonValue::String(
7468            installer
7469                .and_then(|document| json_string(document, "ManifestVersion"))
7470                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
7471                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
7472                .unwrap_or_else(|| "1.10.0".to_owned()),
7473        ),
7474    );
7475    singleton.remove("DefaultLocale");
7476
7477    JsonValue::Object(singleton)
7478}
7479
7480fn is_merged_manifest_document(document: &JsonValue) -> bool {
7481    document
7482        .get("ManifestType")
7483        .and_then(JsonValue::as_str)
7484        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
7485}
7486
7487fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
7488    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
7489    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
7490    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7491    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
7492
7493    let version_document = serde_json::json!({
7494        "PackageIdentifier": package_identifier,
7495        "PackageVersion": package_version,
7496        "DefaultLocale": package_locale,
7497        "ManifestType": "version",
7498        "ManifestVersion": manifest_version,
7499    });
7500
7501    let mut default_locale_document = project_manifest_document(
7502        merged,
7503        &[
7504            "PackageIdentifier",
7505            "PackageVersion",
7506            "PackageLocale",
7507            "Publisher",
7508            "PublisherUrl",
7509            "PublisherSupportUrl",
7510            "PrivacyUrl",
7511            "Author",
7512            "PackageName",
7513            "PackageUrl",
7514            "License",
7515            "LicenseUrl",
7516            "Copyright",
7517            "CopyrightUrl",
7518            "ShortDescription",
7519            "Description",
7520            "Moniker",
7521            "Tags",
7522            "Agreements",
7523            "ReleaseNotes",
7524            "ReleaseNotesUrl",
7525            "PurchaseUrl",
7526            "InstallationNotes",
7527            "Documentations",
7528            "Icons",
7529        ],
7530    );
7531    default_locale_document.insert(
7532        "PackageIdentifier".to_owned(),
7533        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7534    );
7535    default_locale_document.insert(
7536        "PackageVersion".to_owned(),
7537        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7538    );
7539    default_locale_document.insert(
7540        "PackageLocale".to_owned(),
7541        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
7542    );
7543    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7544    default_locale_document.insert(
7545        "ManifestVersion".to_owned(),
7546        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7547    );
7548
7549    let mut installer_document = project_manifest_document(
7550        merged,
7551        &[
7552            "PackageIdentifier",
7553            "PackageVersion",
7554            "Channel",
7555            "InstallerLocale",
7556            "Platform",
7557            "MinimumOSVersion",
7558            "InstallerType",
7559            "NestedInstallerType",
7560            "NestedInstallerFiles",
7561            "Scope",
7562            "InstallModes",
7563            "InstallerSwitches",
7564            "InstallerSuccessCodes",
7565            "ExpectedReturnCodes",
7566            "UpgradeBehavior",
7567            "Commands",
7568            "Protocols",
7569            "FileExtensions",
7570            "Dependencies",
7571            "PackageFamilyName",
7572            "ProductCode",
7573            "Capabilities",
7574            "RestrictedCapabilities",
7575            "Markets",
7576            "InstallerAbortsTerminal",
7577            "ReleaseDate",
7578            "InstallLocationRequired",
7579            "RequireExplicitUpgrade",
7580            "DisplayInstallWarnings",
7581            "UnsupportedOSArchitectures",
7582            "UnsupportedArguments",
7583            "AppsAndFeaturesEntries",
7584            "ElevationRequirement",
7585            "InstallationMetadata",
7586            "DownloadCommandProhibited",
7587            "RepairBehavior",
7588            "ArchiveBinariesDependOnPath",
7589            "Authentication",
7590            "Installers",
7591        ],
7592    );
7593    installer_document.insert(
7594        "PackageIdentifier".to_owned(),
7595        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7596    );
7597    installer_document.insert(
7598        "PackageVersion".to_owned(),
7599        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7600    );
7601    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7602    installer_document.insert(
7603        "ManifestVersion".to_owned(),
7604        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7605    );
7606
7607    vec![
7608        version_document,
7609        JsonValue::Object(default_locale_document),
7610        JsonValue::Object(installer_document),
7611    ]
7612}
7613
7614fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
7615    let mut result = JsonMap::new();
7616    if let Some(source) = source.as_object() {
7617        for key in keys {
7618            if let Some(value) = source.get(*key) {
7619                result.insert((*key).to_owned(), value.clone());
7620            }
7621        }
7622    }
7623
7624    result
7625}
7626
7627fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
7628    if let Some(source) = source.and_then(JsonValue::as_object) {
7629        for key in keys {
7630            if let Some(value) = source.get(*key) {
7631                target.insert((*key).to_owned(), value.clone());
7632            }
7633        }
7634    }
7635}
7636
7637fn copy_all_manifest_keys_except(
7638    target: &mut JsonMap<String, JsonValue>,
7639    source: Option<&JsonValue>,
7640    excluded_keys: &[&str],
7641) {
7642    if let Some(source) = source.and_then(JsonValue::as_object) {
7643        for (key, value) in source {
7644            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
7645                target.insert(key.clone(), value.clone());
7646            }
7647        }
7648    }
7649}
7650
7651fn build_rest_manifest_documents(
7652    data: &JsonValue,
7653    installer_source: &JsonValue,
7654    default_locale: &JsonValue,
7655    package_id: &str,
7656    version: &str,
7657    channel: &str,
7658) -> JsonValue {
7659    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
7660    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7661    let manifest_version = json_string(installer_source, "ManifestVersion")
7662        .or_else(|| json_string(default_locale, "ManifestVersion"))
7663        .or_else(|| json_string(data, "ManifestVersion"))
7664        .unwrap_or_else(|| "1.10.0".to_owned());
7665
7666    let mut version_document = JsonMap::new();
7667    version_document.insert(
7668        "PackageIdentifier".to_owned(),
7669        JsonValue::String(package_identifier.clone()),
7670    );
7671    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7672    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
7673    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
7674    version_document.insert(
7675        "ManifestVersion".to_owned(),
7676        JsonValue::String(manifest_version.clone()),
7677    );
7678
7679    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
7680    default_locale_document.insert(
7681        "PackageIdentifier".to_owned(),
7682        JsonValue::String(package_identifier.clone()),
7683    );
7684    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7685    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7686    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7687    default_locale_document.insert(
7688        "ManifestVersion".to_owned(),
7689        JsonValue::String(manifest_version.clone()),
7690    );
7691
7692    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
7693    installer_document.remove("DefaultLocale");
7694    installer_document.remove("Locales");
7695    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
7696    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7697    if !channel.is_empty() {
7698        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
7699    }
7700    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7701    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
7702
7703    let documents = vec![
7704        JsonValue::Object(version_document),
7705        JsonValue::Object(default_locale_document),
7706        JsonValue::Object(installer_document),
7707    ];
7708
7709    merge_manifest_documents(&documents)
7710}
7711
7712fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
7713    let base = installer_defaults(root);
7714    let base_switches = yaml_installer_switches(root);
7715    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
7716        let installers = items
7717            .iter()
7718            .filter_map(YamlValue::as_mapping)
7719            .map(|item| {
7720                let mut merged = base.clone();
7721                merge_yaml_mapping(&mut merged, item);
7722                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
7723                installer_from_yaml(&merged, switches)
7724            })
7725            .collect::<Vec<_>>();
7726        if !installers.is_empty() {
7727            return installers;
7728        }
7729    }
7730
7731    if base.is_empty() {
7732        Vec::new()
7733    } else {
7734        vec![installer_from_yaml(&base, base_switches)]
7735    }
7736}
7737
7738fn installer_defaults(root: &YamlMapping) -> YamlMapping {
7739    let keys = [
7740        "Architecture",
7741        "InstallerType",
7742        "NestedInstallerType",
7743        "NestedInstallerFiles",
7744        "InstallerUrl",
7745        "InstallerSha256",
7746        "ProductCode",
7747        "InstallerLocale",
7748        "Platform",
7749        "MinimumOSVersion",
7750        "Scope",
7751        "ReleaseDate",
7752        "PackageFamilyName",
7753        "UpgradeCode",
7754        "Commands",
7755    ];
7756    let mut defaults = YamlMapping::new();
7757    for key in keys {
7758        if let Some(value) = root.get(YamlValue::from(key)) {
7759            defaults.insert(YamlValue::from(key), value.clone());
7760        }
7761    }
7762    defaults
7763}
7764
7765fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
7766    Installer {
7767        architecture: yaml_string(root, "Architecture"),
7768        installer_type: yaml_string(root, "InstallerType"),
7769        nested_installer_type: yaml_string(root, "NestedInstallerType"),
7770        nested_installer_files: yaml_nested_installer_files(root),
7771        url: yaml_string(root, "InstallerUrl"),
7772        sha256: yaml_string(root, "InstallerSha256"),
7773        product_code: yaml_string(root, "ProductCode"),
7774        locale: yaml_string(root, "InstallerLocale"),
7775        scope: yaml_string(root, "Scope"),
7776        release_date: yaml_string(root, "ReleaseDate"),
7777        package_family_name: yaml_string(root, "PackageFamilyName"),
7778        upgrade_code: yaml_string(root, "UpgradeCode"),
7779        platforms: yaml_string_list(root, "Platform"),
7780        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
7781        switches,
7782        commands: yaml_string_list(root, "Commands"),
7783        package_dependencies: yaml_package_dependencies(root),
7784        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
7785    }
7786}
7787
7788/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
7789/// the legacy `True`/`False` variants the winget catalog mixes in.
7790fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
7791    let Some(value) = root.get(YamlValue::from(key)) else {
7792        return false;
7793    };
7794    match value {
7795        YamlValue::Bool(b) => *b,
7796        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
7797        _ => false,
7798    }
7799}
7800
7801fn json_bool(value: &JsonValue, key: &str) -> bool {
7802    match value.get(key) {
7803        Some(JsonValue::Bool(b)) => *b,
7804        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
7805        _ => false,
7806    }
7807}
7808
7809fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
7810    let mapping = root
7811        .get(YamlValue::from("InstallerSwitches"))
7812        .or_else(|| root.get(YamlValue::from("Switches")))
7813        .and_then(YamlValue::as_mapping);
7814    yaml_installer_switches_from_mapping(mapping)
7815}
7816
7817fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
7818    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
7819
7820    InstallerSwitches {
7821        silent: string("Silent"),
7822        silent_with_progress: string("SilentWithProgress"),
7823        interactive: string("Interactive"),
7824        custom: string("Custom"),
7825        log: string("Log"),
7826        install_location: string("InstallLocation"),
7827    }
7828}
7829
7830fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
7831    yaml_string(root, key).or_else(|| {
7832        root.get(YamlValue::from("DefaultLocale"))
7833            .and_then(YamlValue::as_mapping)
7834            .and_then(|mapping| yaml_string(mapping, key))
7835    })
7836}
7837
7838fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
7839    yaml_string(root, key)
7840}
7841
7842fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
7843    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
7844}
7845
7846fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
7847    match value {
7848        YamlValue::Null => None,
7849        YamlValue::Bool(value) => Some(value.to_string()),
7850        YamlValue::Number(value) => Some(value.to_string()),
7851        YamlValue::String(value) => Some(value.clone()),
7852        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
7853        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
7854    }
7855}
7856
7857fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
7858    root.get(YamlValue::from(key))
7859        .and_then(YamlValue::as_sequence)
7860        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
7861        .unwrap_or_default()
7862}
7863
7864fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
7865    root.get(YamlValue::from("Documentations"))
7866        .and_then(YamlValue::as_sequence)
7867        .map(|items| {
7868            items
7869                .iter()
7870                .filter_map(YamlValue::as_mapping)
7871                .filter_map(|item| {
7872                    let url = yaml_string(item, "DocumentUrl")?;
7873                    Some(Documentation {
7874                        label: yaml_string(item, "DocumentLabel"),
7875                        url,
7876                    })
7877                })
7878                .collect()
7879        })
7880        .unwrap_or_default()
7881}
7882
7883fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
7884    root.get(YamlValue::from("Icons"))
7885        .and_then(YamlValue::as_sequence)
7886        .map(|items| {
7887            items
7888                .iter()
7889                .filter_map(YamlValue::as_mapping)
7890                .filter_map(|item| {
7891                    let icon = PackageIcon {
7892                        icon_url: yaml_string(item, "IconUrl"),
7893                        icon_file_type: yaml_string(item, "IconFileType"),
7894                        icon_resolution: yaml_string(item, "IconResolution"),
7895                        icon_theme: yaml_string(item, "IconTheme"),
7896                        icon_sha256: yaml_string(item, "IconSha256"),
7897                    };
7898                    (!icon.is_empty()).then_some(icon)
7899                })
7900                .collect()
7901        })
7902        .unwrap_or_default()
7903}
7904
7905fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
7906    root.get(YamlValue::from("Agreements"))
7907        .and_then(YamlValue::as_sequence)
7908        .map(|items| {
7909            items
7910                .iter()
7911                .filter_map(YamlValue::as_mapping)
7912                .map(|item| PackageAgreement {
7913                    label: yaml_string(item, "AgreementLabel"),
7914                    text: yaml_string(item, "Agreement"),
7915                    url: yaml_string(item, "AgreementUrl"),
7916                })
7917                .collect()
7918        })
7919        .unwrap_or_default()
7920}
7921
7922fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
7923    root.get(YamlValue::from("Dependencies"))
7924        .and_then(YamlValue::as_mapping)
7925        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
7926        .and_then(YamlValue::as_sequence)
7927        .map(|items| {
7928            items
7929                .iter()
7930                .filter_map(YamlValue::as_mapping)
7931                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
7932                .collect()
7933        })
7934        .unwrap_or_default()
7935}
7936
7937fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
7938    root.get(YamlValue::from("NestedInstallerFiles"))
7939        .and_then(YamlValue::as_sequence)
7940        .map(|items| {
7941            items
7942                .iter()
7943                .filter_map(YamlValue::as_mapping)
7944                .filter_map(|item| {
7945                    let relative = yaml_string(item, "RelativeFilePath")?;
7946                    Some(NestedInstallerFile {
7947                        relative_file_path: relative,
7948                        portable_command_alias: yaml_string(item, "PortableCommandAlias"),
7949                    })
7950                })
7951                .collect()
7952        })
7953        .unwrap_or_default()
7954}
7955
7956fn json_string(value: &JsonValue, key: &str) -> Option<String> {
7957    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
7958}
7959
7960fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
7961    value
7962        .get(key)
7963        .and_then(JsonValue::as_array)
7964        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7965        .unwrap_or_default()
7966}
7967
7968fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7969    value
7970        .get("Documentations")
7971        .and_then(JsonValue::as_array)
7972        .map(|items| {
7973            items
7974                .iter()
7975                .filter_map(|item| {
7976                    let url = json_string(item, "DocumentUrl")?;
7977                    Some(Documentation {
7978                        label: json_string(item, "DocumentLabel"),
7979                        url,
7980                    })
7981                })
7982                .collect()
7983        })
7984        .unwrap_or_default()
7985}
7986
7987fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
7988    value
7989        .get("Icons")
7990        .and_then(JsonValue::as_array)
7991        .map(|items| {
7992            items
7993                .iter()
7994                .filter_map(|item| {
7995                    let icon = PackageIcon {
7996                        icon_url: json_string(item, "IconUrl"),
7997                        icon_file_type: json_string(item, "IconFileType"),
7998                        icon_resolution: json_string(item, "IconResolution"),
7999                        icon_theme: json_string(item, "IconTheme"),
8000                        icon_sha256: json_string(item, "IconSha256"),
8001                    };
8002                    (!icon.is_empty()).then_some(icon)
8003                })
8004                .collect()
8005        })
8006        .unwrap_or_default()
8007}
8008
8009fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
8010    value
8011        .get("Agreements")
8012        .and_then(JsonValue::as_array)
8013        .map(|items| {
8014            items
8015                .iter()
8016                .map(|item| PackageAgreement {
8017                    label: json_string(item, "AgreementLabel"),
8018                    text: json_string(item, "Agreement"),
8019                    url: json_string(item, "AgreementUrl"),
8020                })
8021                .collect()
8022        })
8023        .unwrap_or_default()
8024}
8025
8026fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
8027    value
8028        .get("Dependencies")
8029        .and_then(JsonValue::as_object)
8030        .and_then(|deps| deps.get("PackageDependencies"))
8031        .and_then(JsonValue::as_array)
8032        .map(|items| {
8033            items
8034                .iter()
8035                .filter_map(|item| json_string(item, "PackageIdentifier"))
8036                .collect()
8037        })
8038        .unwrap_or_default()
8039}
8040
8041fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
8042    value
8043        .get("NestedInstallerFiles")
8044        .and_then(JsonValue::as_array)
8045        .map(|items| {
8046            items
8047                .iter()
8048                .filter_map(|item| {
8049                    let relative = json_string(item, "RelativeFilePath")?;
8050                    Some(NestedInstallerFile {
8051                        relative_file_path: relative,
8052                        portable_command_alias: json_string(item, "PortableCommandAlias"),
8053                    })
8054                })
8055                .collect()
8056        })
8057        .unwrap_or_default()
8058}
8059
8060fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
8061    let switches = value
8062        .get("InstallerSwitches")
8063        .or_else(|| value.get("Switches"))
8064        .and_then(JsonValue::as_object);
8065
8066    let string = |key: &str| {
8067        switches
8068            .and_then(|switches| switches.get(key))
8069            .and_then(JsonValue::as_str)
8070            .map(str::to_owned)
8071    };
8072
8073    InstallerSwitches {
8074        silent: string("Silent"),
8075        silent_with_progress: string("SilentWithProgress"),
8076        interactive: string("Interactive"),
8077        custom: string("Custom"),
8078        log: string("Log"),
8079        install_location: string("InstallLocation"),
8080    }
8081}
8082
8083fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
8084    for (key, value) in source {
8085        target.insert(key.clone(), value.clone());
8086    }
8087}
8088
8089fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
8090    let reader = Cursor::new(bytes.to_vec());
8091    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
8092    let mut file = archive
8093        .by_name(member_name)
8094        .with_context(|| format!("zip payload missing {member_name}"))?;
8095    let mut output = Vec::new();
8096    file.read_to_end(&mut output)
8097        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
8098    Ok(output)
8099}
8100
8101fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
8102    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
8103        return Ok(output);
8104    }
8105
8106    if !bytes.starts_with(b"CK")
8107        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
8108    {
8109        return decompress_mszyml_payload(&bytes[offset + 2..]);
8110    }
8111
8112    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
8113}
8114
8115fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
8116    let mut decoder = flate2::read::DeflateDecoder::new(payload);
8117    let mut output = String::new();
8118    decoder.read_to_string(&mut output)?;
8119    Ok(output)
8120}
8121
8122fn cache_control_max_age(response: &Response) -> u64 {
8123    response
8124        .headers()
8125        .get(reqwest::header::CACHE_CONTROL)
8126        .and_then(|value| value.to_str().ok())
8127        .and_then(|header| {
8128            header.split(',').find_map(|part| {
8129                let trimmed = part.trim();
8130                trimmed
8131                    .strip_prefix("max-age=")
8132                    .and_then(|value| value.parse::<u64>().ok())
8133            })
8134        })
8135        .unwrap_or(60)
8136}
8137
8138fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
8139    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
8140        let (major, minor) = major_minor(candidate);
8141        server_versions.iter().any(|version| {
8142            let (server_major, server_minor) = major_minor(version);
8143            server_major == major && server_minor == minor
8144        })
8145    })
8146}
8147
8148fn major_minor(version: &str) -> (String, String) {
8149    let mut parts = version.split('.');
8150    (
8151        parts.next().unwrap_or_default().to_owned(),
8152        parts.next().unwrap_or_default().to_owned(),
8153    )
8154}
8155
8156fn default_market() -> String {
8157    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
8158}
8159
8160fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
8161    let requested_locale = query.locale.as_deref();
8162    let requested_architecture = query.installer_architecture.as_deref();
8163    let requested_type = query.installer_type.as_deref();
8164    let requested_scope = query.install_scope.as_deref();
8165    let requested_platform = query.platform.as_deref();
8166    let requested_os_version = query.os_version.as_deref();
8167    let system_architecture = current_architecture();
8168    let system_platform = current_platform();
8169    let current_os_version = current_os_version();
8170
8171    installers
8172        .iter()
8173        .enumerate()
8174        .filter(|(_, installer)| {
8175            installer_matches_requested(
8176                installer,
8177                requested_type,
8178                requested_scope,
8179                requested_platform,
8180                requested_os_version,
8181                system_platform,
8182                current_os_version.as_deref(),
8183            )
8184        })
8185        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
8186        .max_by_key(|(index, installer)| {
8187            (
8188                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
8189                std::cmp::Reverse(*index),
8190            )
8191        })
8192        .map(|(_, installer)| installer)
8193        .cloned()
8194}
8195
8196fn installer_matches_requested(
8197    installer: &Installer,
8198    requested_type: Option<&str>,
8199    requested_scope: Option<&str>,
8200    requested_platform: Option<&str>,
8201    requested_os_version: Option<&str>,
8202    system_platform: Option<&str>,
8203    current_os_version: Option<&str>,
8204) -> bool {
8205    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
8206        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
8207        && installer_matches_platform(installer, requested_platform, system_platform)
8208        && installer_matches_os_version(installer, requested_os_version, current_os_version)
8209}
8210
8211fn installer_matches_platform(
8212    installer: &Installer,
8213    requested_platform: Option<&str>,
8214    system_platform: Option<&str>,
8215) -> bool {
8216    if installer.platforms.is_empty() {
8217        return true;
8218    }
8219
8220    let Some(effective_platform) = requested_platform.or(system_platform) else {
8221        return true;
8222    };
8223
8224    installer
8225        .platforms
8226        .iter()
8227        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
8228}
8229
8230fn installer_matches_os_version(
8231    installer: &Installer,
8232    requested_os_version: Option<&str>,
8233    current_os_version: Option<&str>,
8234) -> bool {
8235    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
8236        return true;
8237    };
8238
8239    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
8240        return true;
8241    };
8242
8243    match (
8244        parse_dotted_version(minimum_os_version),
8245        parse_dotted_version(actual_os_version),
8246    ) {
8247        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
8248        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
8249    }
8250}
8251
8252fn installer_matches_architecture(
8253    installer: &Installer,
8254    requested_architecture: Option<&str>,
8255    system_architecture: &str,
8256) -> bool {
8257    let Some(architecture) = installer.architecture.as_deref() else {
8258        return true;
8259    };
8260
8261    let architecture = architecture.to_ascii_lowercase();
8262    if let Some(requested) = requested_architecture {
8263        return architecture.eq_ignore_ascii_case(requested);
8264    }
8265
8266    preferred_architectures(system_architecture)
8267        .iter()
8268        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
8269}
8270
8271fn installer_rank(
8272    installer: &Installer,
8273    requested_locale: Option<&str>,
8274    requested_architecture: Option<&str>,
8275    system_architecture: &str,
8276) -> (i32, i32, i32) {
8277    let architecture_rank = architecture_rank(
8278        installer.architecture.as_deref(),
8279        requested_architecture.unwrap_or(system_architecture),
8280        requested_architecture.is_some(),
8281        system_architecture,
8282    );
8283    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
8284    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
8285    (architecture_rank, locale_rank, command_rank)
8286}
8287
8288fn architecture_rank(
8289    installer_architecture: Option<&str>,
8290    preferred_architecture: &str,
8291    strict: bool,
8292    system_architecture: &str,
8293) -> i32 {
8294    let Some(value) = installer_architecture else {
8295        return 0;
8296    };
8297    if value.eq_ignore_ascii_case(preferred_architecture) {
8298        return 5;
8299    }
8300    if value.eq_ignore_ascii_case("neutral") {
8301        return 4;
8302    }
8303    if strict {
8304        return -1;
8305    }
8306
8307    preferred_architectures(system_architecture)
8308        .iter()
8309        .rev()
8310        .position(|candidate| value.eq_ignore_ascii_case(candidate))
8311        .and_then(|index| i32::try_from(index).ok())
8312        .and_then(|index| index.checked_add(1))
8313        .unwrap_or(-1)
8314}
8315
8316/// True when at least one installer in `manifest.installers` targets an
8317/// architecture the host can run. winget hides upgrades whose latest
8318/// version publishes no compatible installer.
8319fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
8320    if manifest.installers.is_empty() {
8321        return true;
8322    }
8323    let allowed = preferred_architectures(current_architecture());
8324    manifest.installers.iter().any(|installer| {
8325        let arch = installer.architecture.as_deref().unwrap_or("neutral");
8326        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
8327    })
8328}
8329
8330fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
8331    match system_architecture {
8332        "arm64" => &["arm64", "neutral", "x64", "x86"],
8333        "x64" => &["x64", "neutral", "x86"],
8334        "x86" => &["x86", "neutral"],
8335        _ => &["neutral"],
8336    }
8337}
8338
8339fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
8340    match (installer_locale, requested_locale) {
8341        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
8342        (Some(installer), Some(requested))
8343            if installer
8344                .split('-')
8345                .next()
8346                .zip(requested.split('-').next())
8347                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
8348        {
8349            2
8350        }
8351        (None, Some(_)) => 1,
8352        (Some(_), None) => 1,
8353        (None, None) => 0,
8354        _ => -1,
8355    }
8356}
8357
8358fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
8359    match requested {
8360        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
8361        None => true,
8362    }
8363}
8364
8365#[cfg(windows)]
8366fn current_architecture() -> &'static str {
8367    use std::sync::OnceLock;
8368    static CELL: OnceLock<&'static str> = OnceLock::new();
8369    CELL.get_or_init(|| {
8370        // IsWow64Process2's `native_machine` is the OS architecture even
8371        // when the process is running under emulation (x64 binary on
8372        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
8373        // mis-report the host arch in that case.
8374        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
8375        let mut process_machine: u16 = 0;
8376        let mut native_machine: u16 = 0;
8377        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
8378        // both out-pointers reference local stack u16s that outlive the call.
8379        let handle = unsafe { GetCurrentProcess() };
8380        // SAFETY: handle is valid per above; pointers are valid.
8381        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
8382        if ok != 0 {
8383            match native_machine {
8384                0xAA64 => return "arm64",
8385                0x8664 => return "x64",
8386                0x014C => return "x86",
8387                _ => {}
8388            }
8389        }
8390        compiled_architecture()
8391    })
8392}
8393
8394#[cfg(not(windows))]
8395fn current_architecture() -> &'static str {
8396    compiled_architecture()
8397}
8398
8399fn compiled_architecture() -> &'static str {
8400    match std::env::consts::ARCH {
8401        "x86_64" => "x64",
8402        "x86" => "x86",
8403        "aarch64" => "arm64",
8404        _ => "neutral",
8405    }
8406}
8407
8408fn current_platform() -> Option<&'static str> {
8409    if cfg!(windows) { Some("Windows.Desktop") } else { None }
8410}
8411
8412#[cfg(windows)]
8413fn current_os_version() -> Option<String> {
8414    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8415    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
8416    let build: String = key.get_value("CurrentBuildNumber").ok()?;
8417    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
8418    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
8419    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
8420    Some(format!("{major}.{minor}.{build}.{ubr}"))
8421}
8422
8423#[cfg(not(windows))]
8424fn current_os_version() -> Option<String> {
8425    None
8426}
8427
8428fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
8429    let mut parsed = Vec::new();
8430    for component in version.split('.') {
8431        if component.is_empty() {
8432            continue;
8433        }
8434        parsed.push(component.parse().ok()?);
8435    }
8436
8437    if parsed.is_empty() { None } else { Some(parsed) }
8438}
8439
8440fn select_v1_version(
8441    versions: &[V1VersionRow],
8442    requested_version: Option<&str>,
8443    requested_channel: Option<&str>,
8444    package_id: &str,
8445    source_name: &str,
8446) -> Result<V1VersionRow> {
8447    let mut ordered = versions.to_vec();
8448    ordered.sort_by(|left, right| {
8449        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8450    });
8451
8452    if let Some(version) = requested_version {
8453        let channel = requested_channel.unwrap_or_default();
8454        return ordered
8455            .iter()
8456            .find(|candidate| {
8457                candidate.version.eq_ignore_ascii_case(version)
8458                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8459            })
8460            .cloned()
8461            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8462    }
8463
8464    if let Some(channel) = requested_channel {
8465        return ordered
8466            .iter()
8467            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8468            .cloned()
8469            .ok_or_else(|| {
8470                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8471            });
8472    }
8473
8474    ordered
8475        .into_iter()
8476        .next()
8477        .ok_or_else(|| anyhow!("package had no versions"))
8478}
8479
8480fn select_v2_version(
8481    versions: &[PackageVersionDataEntry],
8482    requested_version: Option<&str>,
8483    requested_channel: Option<&str>,
8484    package_id: &str,
8485    source_name: &str,
8486) -> Result<PackageVersionDataEntry> {
8487    let mut ordered = versions.to_vec();
8488    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
8489
8490    if let Some(channel) = requested_channel {
8491        bail!(
8492            "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."
8493        );
8494    }
8495
8496    if let Some(version) = requested_version {
8497        return ordered
8498            .iter()
8499            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
8500            .cloned()
8501            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8502    }
8503
8504    ordered
8505        .into_iter()
8506        .next()
8507        .ok_or_else(|| anyhow!("package had no V2 versions"))
8508}
8509
8510fn missing_package_version(
8511    package_id: &str,
8512    source_name: &str,
8513    requested_version: &str,
8514    requested_channel: Option<&str>,
8515) -> anyhow::Error {
8516    anyhow!(MissingPackageVersionError {
8517        package_id: package_id.to_owned(),
8518        source_name: source_name.to_owned(),
8519        requested_version: requested_version.to_owned(),
8520        requested_channel: requested_channel.map(str::to_owned),
8521    })
8522}
8523
8524fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
8525    if let Some(refresh_error) = refresh.error.as_deref() {
8526        let missing_error = error.to_string();
8527        return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
8528    }
8529
8530    Err(error)
8531}
8532
8533fn select_rest_version<'a>(
8534    versions: &'a [VersionKey],
8535    requested_version: Option<&str>,
8536    requested_channel: Option<&str>,
8537    package_id: &str,
8538    source_name: &str,
8539) -> Result<&'a VersionKey> {
8540    if let Some(version) = requested_version {
8541        let channel = requested_channel.unwrap_or_default();
8542        return versions
8543            .iter()
8544            .find(|candidate| {
8545                candidate.version.eq_ignore_ascii_case(version)
8546                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8547            })
8548            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8549    }
8550
8551    if let Some(channel) = requested_channel {
8552        return versions
8553            .iter()
8554            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8555            .ok_or_else(|| {
8556                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8557            });
8558    }
8559
8560    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
8561}
8562
8563fn sort_versions_desc(versions: &mut [VersionKey]) {
8564    versions.sort_by(|left, right| {
8565        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8566    });
8567}
8568
8569fn compare_version_and_channel(
8570    left_version: &str,
8571    left_channel: &str,
8572    right_version: &str,
8573    right_channel: &str,
8574) -> Ordering {
8575    compare_version(left_version, right_version).then_with(|| {
8576        left_channel
8577            .to_ascii_lowercase()
8578            .cmp(&right_channel.to_ascii_lowercase())
8579    })
8580}
8581
8582fn compare_version(left: &str, right: &str) -> Ordering {
8583    let left_less_than = left.trim().strip_prefix('<').map(str::trim);
8584    let right_less_than = right.trim().strip_prefix('<').map(str::trim);
8585    match (left_less_than, right_less_than) {
8586        (Some(left_value), Some(right_value)) => return compare_version(left_value, right_value),
8587        (Some(left_value), None) if compare_version(left_value, right) == Ordering::Equal => return Ordering::Less,
8588        (None, Some(right_value)) if compare_version(left, right_value) == Ordering::Equal => return Ordering::Greater,
8589        _ => {}
8590    }
8591
8592    let left_parts = tokenize_version(left);
8593    let right_parts = tokenize_version(right);
8594    let max_len = left_parts.len().max(right_parts.len());
8595
8596    for index in 0..max_len {
8597        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
8598        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
8599        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
8600
8601        let ordering = if let Some((left_number, right_number)) = numeric {
8602            left_number.cmp(&right_number)
8603        } else {
8604            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
8605        };
8606
8607        if ordering != Ordering::Equal {
8608            return ordering;
8609        }
8610    }
8611
8612    Ordering::Equal
8613}
8614
8615fn tokenize_version(value: &str) -> Vec<String> {
8616    let mut parts = Vec::new();
8617    let mut current = String::new();
8618    let mut current_is_digit = None;
8619
8620    for ch in value.chars() {
8621        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
8622            if !current.is_empty() {
8623                parts.push(std::mem::take(&mut current));
8624            }
8625            current_is_digit = None;
8626            continue;
8627        }
8628
8629        let is_digit = ch.is_ascii_digit();
8630        match current_is_digit {
8631            Some(previous) if previous != is_digit => {
8632                parts.push(std::mem::take(&mut current));
8633                current.push(ch);
8634                current_is_digit = Some(is_digit);
8635            }
8636            Some(_) => current.push(ch),
8637            None => {
8638                current.push(ch);
8639                current_is_digit = Some(is_digit);
8640            }
8641        }
8642    }
8643
8644    if !current.is_empty() {
8645        parts.push(current);
8646    }
8647
8648    parts
8649}
8650
8651fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
8652    if !hash_matches(Some(expected_hash), bytes) {
8653        bail!("downloaded file hash did not match expected SHA256");
8654    }
8655    Ok(())
8656}
8657
8658#[cfg(test)]
8659fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
8660    let Some(expected_hash) = expected_hash else {
8661        return Ok(());
8662    };
8663    if ignore_security_hash {
8664        return Ok(());
8665    }
8666
8667    if !hash_matches(Some(expected_hash), bytes) {
8668        let actual = sha256_hex(bytes);
8669        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
8670    }
8671    Ok(())
8672}
8673
8674fn verify_installer_hash_hex(expected_hash: Option<&str>, actual_hash: &str, ignore_security_hash: bool) -> Result<()> {
8675    let Some(expected_hash) = expected_hash else {
8676        return Ok(());
8677    };
8678    if ignore_security_hash {
8679        return Ok(());
8680    }
8681
8682    if !actual_hash.eq_ignore_ascii_case(expected_hash) {
8683        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual_hash}");
8684    }
8685    Ok(())
8686}
8687
8688const INSTALLER_DOWNLOAD_ATTEMPTS: usize = 3;
8689const INSTALLER_DOWNLOAD_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(30);
8690const INSTALLER_DOWNLOAD_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(60);
8691const INSTALLER_DOWNLOAD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30 * 60);
8692const INSTALLER_DOWNLOAD_METADATA_INTERVAL: u64 = 4 * 1024 * 1024;
8693
8694#[derive(Debug, Clone, Serialize, Deserialize)]
8695struct InstallerDownloadMetadata {
8696    url: String,
8697    etag: Option<String>,
8698    last_modified: Option<String>,
8699    total_length: Option<u64>,
8700    offset: u64,
8701}
8702
8703#[derive(Debug)]
8704struct DownloadAttempt {
8705    total_length: Option<u64>,
8706}
8707
8708fn download_installer_to_file(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8709    let runtime = tokio::runtime::Builder::new_current_thread()
8710        .enable_all()
8711        .build()
8712        .context("failed to create installer download runtime")?;
8713    runtime.block_on(download_installer_to_file_async(user_agent, url, dest))
8714}
8715
8716async fn download_installer_to_file_async(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8717    let part_path = sidecar_path(dest, "part");
8718    let metadata_path = sidecar_path(dest, "part.json");
8719    let started_at = Instant::now();
8720    let mut last_error = None;
8721
8722    for _attempt in 1..=INSTALLER_DOWNLOAD_ATTEMPTS {
8723        if started_at.elapsed() >= INSTALLER_DOWNLOAD_TOTAL_TIMEOUT {
8724            break;
8725        }
8726
8727        let remaining = INSTALLER_DOWNLOAD_TOTAL_TIMEOUT
8728            .checked_sub(started_at.elapsed())
8729            .unwrap_or_else(|| StdDuration::from_secs(1));
8730        match try_download_installer_to_part(user_agent, url, &part_path, &metadata_path, remaining).await {
8731            Ok(()) => {
8732                let actual_hash = sha256_file_hex(&part_path)?;
8733                replace_file(&part_path, dest)?;
8734                let _ = fs::remove_file(&metadata_path);
8735                return Ok(actual_hash);
8736            }
8737            Err(error) => {
8738                last_error = Some(error);
8739            }
8740        }
8741    }
8742
8743    Err(last_error.unwrap_or_else(|| anyhow!("installer download timed out")))
8744}
8745
8746async fn try_download_installer_to_part(
8747    user_agent: &str,
8748    url: &str,
8749    part_path: &Path,
8750    metadata_path: &Path,
8751    remaining: StdDuration,
8752) -> Result<()> {
8753    let metadata = read_installer_download_metadata(metadata_path, part_path, url)?;
8754    let mut offset = metadata.as_ref().map_or(0, |metadata| metadata.offset);
8755    let mut request = reqwest::Client::builder()
8756        .user_agent(user_agent)
8757        .connect_timeout(INSTALLER_DOWNLOAD_CONNECT_TIMEOUT)
8758        .timeout(remaining)
8759        .build()
8760        .context("failed to build installer download client")?
8761        .get(url)
8762        .header(ACCEPT_ENCODING, "identity");
8763
8764    if offset > 0 {
8765        request = request.header(RANGE, format!("bytes={offset}-"));
8766        if let Some(if_range) = metadata.as_ref().and_then(installer_download_if_range) {
8767            request = request.header(IF_RANGE, if_range);
8768        }
8769    }
8770
8771    let mut response = request.send().await.context("failed to download installer")?;
8772    let headers = response.headers().clone();
8773    let status = response.status();
8774    let attempt = if status == StatusCode::PARTIAL_CONTENT && offset > 0 {
8775        parse_content_range(headers.get(CONTENT_RANGE), offset)?
8776    } else if status == StatusCode::OK {
8777        offset = 0;
8778        DownloadAttempt {
8779            total_length: content_length(&headers),
8780        }
8781    } else if status == StatusCode::RANGE_NOT_SATISFIABLE {
8782        let _ = fs::remove_file(part_path);
8783        let _ = fs::remove_file(metadata_path);
8784        bail!("installer partial download could not be resumed; retrying from the beginning");
8785    } else if status.is_success() {
8786        bail!("unexpected installer download HTTP status {status}");
8787    } else {
8788        bail!("Download failed: HTTP {status}");
8789    };
8790
8791    if offset == 0 {
8792        let _ = fs::remove_file(part_path);
8793    }
8794
8795    let mut file = if offset == 0 {
8796        fs::File::create(part_path).with_context(|| format!("failed to create {}", part_path.display()))?
8797    } else {
8798        OpenOptions::new()
8799            .append(true)
8800            .open(part_path)
8801            .with_context(|| format!("failed to open {}", part_path.display()))?
8802    };
8803
8804    let mut metadata = InstallerDownloadMetadata {
8805        url: url.to_owned(),
8806        etag: strong_etag(&headers),
8807        last_modified: header_string(&headers, LAST_MODIFIED),
8808        total_length: attempt.total_length,
8809        offset,
8810    };
8811    write_installer_download_metadata(metadata_path, &metadata)?;
8812
8813    let mut next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8814    while let Some(chunk) = tokio::time::timeout(INSTALLER_DOWNLOAD_IDLE_TIMEOUT, response.chunk())
8815        .await
8816        .context("installer download stalled while waiting for data")?
8817        .context("failed to read installer response body")?
8818    {
8819        file.write_all(&chunk)
8820            .with_context(|| format!("failed to write {}", part_path.display()))?;
8821        offset += u64::try_from(chunk.len()).expect("chunk length fits in u64");
8822
8823        if offset >= next_metadata_offset {
8824            file.flush()
8825                .with_context(|| format!("failed to flush {}", part_path.display()))?;
8826            metadata.offset = offset;
8827            write_installer_download_metadata(metadata_path, &metadata)?;
8828            next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8829        }
8830    }
8831
8832    file.flush()
8833        .with_context(|| format!("failed to flush {}", part_path.display()))?;
8834    file.sync_all()
8835        .with_context(|| format!("failed to sync {}", part_path.display()))?;
8836
8837    if let Some(total_length) = attempt.total_length
8838        && offset != total_length
8839    {
8840        bail!("installer download ended early: received {offset} of {total_length} bytes");
8841    }
8842
8843    metadata.offset = offset;
8844    metadata.total_length = attempt.total_length;
8845    write_installer_download_metadata(metadata_path, &metadata)?;
8846    Ok(())
8847}
8848
8849fn read_installer_download_metadata(
8850    metadata_path: &Path,
8851    part_path: &Path,
8852    url: &str,
8853) -> Result<Option<InstallerDownloadMetadata>> {
8854    let Some(mut metadata) = fs::read(metadata_path)
8855        .ok()
8856        .and_then(|bytes| serde_json::from_slice::<InstallerDownloadMetadata>(&bytes).ok())
8857    else {
8858        let _ = fs::remove_file(part_path);
8859        let _ = fs::remove_file(metadata_path);
8860        return Ok(None);
8861    };
8862
8863    if metadata.url != url || metadata.offset == 0 {
8864        let _ = fs::remove_file(part_path);
8865        let _ = fs::remove_file(metadata_path);
8866        return Ok(None);
8867    }
8868
8869    let part_len = fs::metadata(part_path).map(|metadata| metadata.len()).unwrap_or(0);
8870    metadata.offset = metadata.offset.min(part_len);
8871    if metadata.offset == 0 {
8872        let _ = fs::remove_file(part_path);
8873        let _ = fs::remove_file(metadata_path);
8874        return Ok(None);
8875    }
8876
8877    OpenOptions::new()
8878        .write(true)
8879        .open(part_path)
8880        .with_context(|| format!("failed to open {}", part_path.display()))?
8881        .set_len(metadata.offset)
8882        .with_context(|| format!("failed to truncate {}", part_path.display()))?;
8883
8884    Ok(Some(metadata))
8885}
8886
8887fn write_installer_download_metadata(path: &Path, metadata: &InstallerDownloadMetadata) -> Result<()> {
8888    let bytes = serde_json::to_vec(metadata).context("failed to serialize installer download metadata")?;
8889    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
8890}
8891
8892fn installer_download_if_range(metadata: &InstallerDownloadMetadata) -> Option<String> {
8893    metadata
8894        .etag
8895        .as_ref()
8896        .filter(|etag| !etag.starts_with("W/"))
8897        .cloned()
8898        .or_else(|| metadata.last_modified.clone())
8899}
8900
8901fn parse_content_range(value: Option<&HeaderValue>, expected_start: u64) -> Result<DownloadAttempt> {
8902    let value = value
8903        .and_then(|value| value.to_str().ok())
8904        .ok_or_else(|| anyhow!("partial installer response missing Content-Range"))?;
8905    let range = value
8906        .strip_prefix("bytes ")
8907        .ok_or_else(|| anyhow!("unsupported Content-Range value: {value}"))?;
8908    let (span, total) = range
8909        .split_once('/')
8910        .ok_or_else(|| anyhow!("invalid Content-Range value: {value}"))?;
8911    let (start, _) = span
8912        .split_once('-')
8913        .ok_or_else(|| anyhow!("invalid Content-Range byte span: {value}"))?;
8914    let start = start
8915        .parse::<u64>()
8916        .with_context(|| format!("invalid Content-Range start: {value}"))?;
8917    if start != expected_start {
8918        bail!("partial installer response started at {start}, expected {expected_start}");
8919    }
8920    let total_length = if total == "*" {
8921        None
8922    } else {
8923        Some(
8924            total
8925                .parse::<u64>()
8926                .with_context(|| format!("invalid Content-Range total: {value}"))?,
8927        )
8928    };
8929    Ok(DownloadAttempt { total_length })
8930}
8931
8932fn content_length(headers: &HeaderMap) -> Option<u64> {
8933    headers
8934        .get(CONTENT_LENGTH)
8935        .and_then(|value| value.to_str().ok())
8936        .and_then(|value| value.parse::<u64>().ok())
8937}
8938
8939fn strong_etag(headers: &HeaderMap) -> Option<String> {
8940    header_string(headers, ETAG).filter(|value| !value.starts_with("W/"))
8941}
8942
8943fn header_string(headers: &HeaderMap, name: reqwest::header::HeaderName) -> Option<String> {
8944    headers
8945        .get(name)
8946        .and_then(|value| value.to_str().ok())
8947        .map(str::to_owned)
8948}
8949
8950fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
8951    let mut value = path.as_os_str().to_os_string();
8952    value.push(".");
8953    value.push(suffix);
8954    PathBuf::from(value)
8955}
8956
8957fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
8958    expected_hash
8959        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
8960        .unwrap_or(true)
8961}
8962
8963fn sha256_hex(bytes: &[u8]) -> String {
8964    let digest = Sha256::digest(bytes);
8965    sha256_digest_hex(digest)
8966}
8967
8968fn sha256_file_hex(path: &Path) -> Result<String> {
8969    let mut file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
8970    let mut hasher = Sha256::new();
8971    let mut buffer = [0u8; 128 * 1024];
8972    loop {
8973        let read = file
8974            .read(&mut buffer)
8975            .with_context(|| format!("failed to read {}", path.display()))?;
8976        if read == 0 {
8977            break;
8978        }
8979        hasher.update(&buffer[..read]);
8980    }
8981    Ok(sha256_digest_hex(hasher.finalize()))
8982}
8983
8984fn sha256_digest_hex(digest: impl AsRef<[u8]>) -> String {
8985    let digest = digest.as_ref();
8986    let mut output = String::with_capacity(digest.len() * 2);
8987    for byte in digest {
8988        output.push_str(&format!("{byte:02X}"));
8989    }
8990    output
8991}
8992
8993fn pins_db_path(app_root: &Path) -> PathBuf {
8994    if uses_packaged_layout(app_root) {
8995        app_root.join("pinning.db")
8996    } else {
8997        app_root.join("pins.db")
8998    }
8999}
9000
9001fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
9002    let mut has_current_column = false;
9003    let mut has_legacy_column = false;
9004
9005    for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
9006        match column_name.as_str() {
9007            "type" => has_current_column = true,
9008            "pin_type" => has_legacy_column = true,
9009            _ => {}
9010        }
9011    }
9012
9013    Ok(if has_current_column {
9014        Some("type")
9015    } else if has_legacy_column {
9016        Some("pin_type")
9017    } else {
9018        None
9019    })
9020}
9021
9022fn decode_pin_type(pin_type_int: i64) -> PinType {
9023    match pin_type_int {
9024        4 => PinType::Blocking,
9025        3 => PinType::Gating,
9026        _ => PinType::Pinning,
9027    }
9028}
9029
9030#[cfg(windows)]
9031fn dispatch_installer(
9032    installer_path: &Path,
9033    installer_type: &str,
9034    request: &InstallRequest,
9035    manifest: &Manifest,
9036    installer: &Installer,
9037) -> Result<i32> {
9038    match installer_type {
9039        "msi" | "wix" if should_run_msi_direct(request, installer) => run_msi_direct(
9040            installer_path,
9041            &installer_direct_msi_arguments(installer_type, request, manifest, installer_path, installer),
9042        ),
9043        "msi" | "wix" => shell_execute_installer(
9044            Path::new("msiexec.exe"),
9045            &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9046            should_elevate_msi_shell_execute(installer),
9047        ),
9048        "msix" | "appx" => {
9049            use std::process::Command;
9050
9051            let mut cmd = Command::new("powershell");
9052            cmd.arg("-NoProfile")
9053                .arg("-Command")
9054                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
9055            let status = cmd.status().context("failed to run Add-AppxPackage")?;
9056            Ok(status.code().unwrap_or(-1))
9057        }
9058        "zip" => {
9059            // A `zip` at the top-level installer position is treated as portable when
9060            // `NestedInstallerType=portable` is specified — that's the shape every
9061            // portable in the winget catalog uses (e.g. BurntSushi.ripgrep.MSVC).
9062            // Anything else still falls through to the historical extract-to-Programs
9063            // behavior, which isn't really correct but at least doesn't regress.
9064            if installer
9065                .nested_installer_type
9066                .as_deref()
9067                .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9068            {
9069                return install_portable(installer_path, request, manifest, installer);
9070            }
9071            let target = dirs::data_local_dir()
9072                .unwrap_or_else(|| PathBuf::from("."))
9073                .join("Programs");
9074            fs::create_dir_all(&target)?;
9075            let file = fs::File::open(installer_path)?;
9076            let mut archive = ZipArchive::new(file)?;
9077            archive.extract(&target)?;
9078            Ok(0)
9079        }
9080        "portable" => install_portable(installer_path, request, manifest, installer),
9081        // exe, inno, nullsoft, burn, etc. are launched through ShellExecute so
9082        // Windows can honor installer manifests and legacy elevation detection.
9083        _ => shell_execute_installer(
9084            installer_path,
9085            &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9086            false,
9087        ),
9088    }
9089}
9090
9091#[cfg(windows)]
9092fn should_run_msi_direct(request: &InstallRequest, installer: &Installer) -> bool {
9093    request.mode == InstallerMode::Silent && !should_elevate_msi_shell_execute(installer)
9094}
9095
9096#[cfg(windows)]
9097fn should_elevate_msi_shell_execute(installer: &Installer) -> bool {
9098    installer
9099        .scope
9100        .as_deref()
9101        .is_some_and(|scope| scope.eq_ignore_ascii_case("machine"))
9102}
9103
9104#[cfg(windows)]
9105fn shell_execute_installer(installer_path: &Path, args: &[String], run_as: bool) -> Result<i32> {
9106    use std::ffi::OsStr;
9107
9108    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, WAIT_OBJECT_0};
9109    use windows_sys::Win32::System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject};
9110    use windows_sys::Win32::UI::Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW};
9111    use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
9112
9113    let file = wide_null(installer_path.as_os_str());
9114    let parameters = wide_null(OsStr::new(&join_windows_arguments(args)));
9115    let verb = run_as.then(|| wide_null(OsStr::new("runas")));
9116
9117    // SAFETY: SHELLEXECUTEINFOW is a plain Win32 data structure; zero initialization
9118    // is the documented starting point before setting cbSize and the fields we use.
9119    let mut exec_info = unsafe { std::mem::zeroed::<SHELLEXECUTEINFOW>() };
9120    exec_info.cbSize =
9121        u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).expect("SHELLEXECUTEINFOW size fits in u32");
9122    exec_info.fMask = SEE_MASK_NOCLOSEPROCESS;
9123    exec_info.lpFile = file.as_ptr();
9124    exec_info.lpParameters = parameters.as_ptr();
9125    if let Some(verb) = verb.as_ref() {
9126        exec_info.lpVerb = verb.as_ptr();
9127    }
9128    exec_info.nShow = SW_SHOW;
9129
9130    // SAFETY: exec_info points to a valid SHELLEXECUTEINFOW whose string pointers
9131    // reference live, nul-terminated UTF-16 buffers for the duration of the call.
9132    if unsafe { ShellExecuteExW(&mut exec_info) } == 0 {
9133        bail!(
9134            "failed to launch installer through ShellExecute (Win32 error {})",
9135            // SAFETY: GetLastError reads the calling thread's last-error value.
9136            unsafe { GetLastError() }
9137        );
9138    }
9139
9140    if exec_info.hProcess.is_null() {
9141        bail!("ShellExecute did not return an installer process handle");
9142    }
9143
9144    // SAFETY: ShellExecuteExW returned a non-null process handle with
9145    // SEE_MASK_NOCLOSEPROCESS, so waiting on it is valid.
9146    let wait_result = unsafe { WaitForSingleObject(exec_info.hProcess, INFINITE) };
9147    if wait_result != WAIT_OBJECT_0 {
9148        // SAFETY: hProcess is the owned process handle returned by ShellExecuteExW.
9149        let _ = unsafe { CloseHandle(exec_info.hProcess) };
9150        bail!("failed while waiting for installer process (Win32 wait result {wait_result})");
9151    }
9152
9153    let mut exit_code = 0u32;
9154    // SAFETY: hProcess is still open and valid, and exit_code points to writable memory.
9155    let got_exit_code = unsafe { GetExitCodeProcess(exec_info.hProcess, &mut exit_code) };
9156    // SAFETY: hProcess is the owned process handle returned by ShellExecuteExW.
9157    let _ = unsafe { CloseHandle(exec_info.hProcess) };
9158    if got_exit_code == 0 {
9159        // SAFETY: GetLastError reads the calling thread's last-error value.
9160        let error = unsafe { GetLastError() };
9161        bail!("failed to retrieve installer exit code (Win32 error {error})");
9162    }
9163
9164    Ok(exit_code.cast_signed())
9165}
9166
9167#[cfg(windows)]
9168fn wide_null(value: &std::ffi::OsStr) -> Vec<u16> {
9169    value.encode_wide().chain(std::iter::once(0)).collect()
9170}
9171
9172#[cfg(windows)]
9173fn run_msi_direct(installer_path: &Path, args: &[String]) -> Result<i32> {
9174    use std::ffi::OsStr;
9175    use std::ptr::{null, null_mut};
9176
9177    use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
9178        INSTALLUILEVEL_NONE, INSTALLUILEVEL_UACONLY, MsiEnableLogW, MsiInstallProductW, MsiSetInternalUI,
9179    };
9180
9181    let parsed = parse_msi_direct_arguments(args)?;
9182    let package_path = wide_null(installer_path.as_os_str());
9183    let properties = wide_null(OsStr::new(&parsed.properties));
9184    let property_ptr = if parsed.properties.is_empty() {
9185        null()
9186    } else {
9187        properties.as_ptr()
9188    };
9189    let log_path = parsed.log_path.as_ref().map(|path| wide_null(OsStr::new(path)));
9190
9191    if let Some(path) = log_path.as_ref() {
9192        // SAFETY: path is a live, nul-terminated UTF-16 buffer; mode and attributes
9193        // are parsed from MSI logging switches.
9194        let result = unsafe { MsiEnableLogW(parsed.log_mode, path.as_ptr(), parsed.log_attributes) };
9195        if result != 0 {
9196            bail!("failed to enable MSI logging (Win32 error {result})");
9197        }
9198    } else {
9199        // SAFETY: Passing a null file with mode 0 disables MSI logging.
9200        let _ = unsafe { MsiEnableLogW(0, null(), 0) };
9201    }
9202
9203    let ui_level = if parsed.ui_level == INSTALLUILEVEL_NONE as u32 {
9204        (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY) as u32
9205    } else {
9206        parsed.ui_level
9207    };
9208    // SAFETY: ui_level is composed from documented INSTALLUILEVEL flags; null HWND
9209    // matches the MsiSetInternalUI contract when no owner window is supplied.
9210    unsafe { MsiSetInternalUI(ui_level.cast_signed(), null_mut()) };
9211    // SAFETY: package_path and property_ptr are valid, nul-terminated UTF-16
9212    // strings (or null for no properties) for the duration of the call.
9213    let result = unsafe { MsiInstallProductW(package_path.as_ptr(), property_ptr) };
9214    Ok(result.cast_signed())
9215}
9216
9217#[cfg(any(windows, test))]
9218#[derive(Debug, PartialEq, Eq)]
9219struct MsiDirectArguments {
9220    ui_level: u32,
9221    properties: String,
9222    log_mode: u32,
9223    log_path: Option<String>,
9224    log_attributes: u32,
9225}
9226
9227#[cfg(any(windows, test))]
9228fn parse_msi_direct_arguments(args: &[String]) -> Result<MsiDirectArguments> {
9229    const INSTALLUILEVEL_DEFAULT: u32 = 1;
9230    const INSTALLUILEVEL_NONE: u32 = 2;
9231    const INSTALLUILEVEL_BASIC: u32 = 3;
9232    const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9233    const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9234    const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9235    const LOGMODE_DEFAULT: u32 = 1 | 2 | 4 | 16 | 128 | 256 | 512;
9236    const LOGMODE_ALL: u32 = LOGMODE_DEFAULT | 8 | 1024 | 2048;
9237    const LOGMODE_VERBOSE: u32 = 4096;
9238    const LOGMODE_EXTRADEBUG: u32 = 8192;
9239    const LOGATTR_APPEND: u32 = 1;
9240    const LOGATTR_FLUSH: u32 = 2;
9241
9242    let mut parsed = MsiDirectArguments {
9243        ui_level: INSTALLUILEVEL_DEFAULT,
9244        properties: String::new(),
9245        log_mode: 0,
9246        log_path: None,
9247        log_attributes: 0,
9248    };
9249
9250    let mut index = 0;
9251    while index < args.len() {
9252        let token = &args[index];
9253        if is_msi_switch(token) {
9254            let option = &token[1..];
9255            if option.eq_ignore_ascii_case("quiet") {
9256                parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9257            } else if option.eq_ignore_ascii_case("passive") {
9258                parsed.ui_level = INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL;
9259                append_msi_property(&mut parsed.properties, "REBOOTPROMPT=S");
9260            } else if option.eq_ignore_ascii_case("norestart") {
9261                append_msi_property(&mut parsed.properties, "REBOOT=ReallySuppress");
9262            } else if option.eq_ignore_ascii_case("forcerestart") {
9263                append_msi_property(&mut parsed.properties, "REBOOT=Force");
9264            } else if option.eq_ignore_ascii_case("promptrestart") {
9265                append_msi_property(&mut parsed.properties, "REBOOTPROMPT=\"\"");
9266            } else if option.eq_ignore_ascii_case("log") {
9267                index += 1;
9268                let path = args.get(index).ok_or_else(|| anyhow!("MSI /log requires a log path"))?;
9269                parsed.log_mode = LOGMODE_ALL;
9270                parsed.log_path = Some(path.clone());
9271            } else if option.len() == 1 && option.eq_ignore_ascii_case("q") {
9272                parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9273            } else if option.starts_with(['q', 'Q']) {
9274                parsed.ui_level = parse_msi_ui_level(&option[1..])?;
9275            } else if option.starts_with(['l', 'L']) {
9276                index += 1;
9277                let path = args
9278                    .get(index)
9279                    .ok_or_else(|| anyhow!("MSI logging switch requires a log path"))?;
9280                let (mode, attributes) = parse_msi_log_mode(
9281                    &option[1..],
9282                    LOGMODE_DEFAULT,
9283                    LOGMODE_ALL,
9284                    LOGMODE_VERBOSE,
9285                    LOGMODE_EXTRADEBUG,
9286                    LOGATTR_APPEND,
9287                    LOGATTR_FLUSH,
9288                )?;
9289                parsed.log_mode = mode;
9290                parsed.log_attributes = attributes;
9291                parsed.log_path = Some(path.clone());
9292            } else {
9293                bail!("unsupported MSI switch for direct MSI execution: {token}");
9294            }
9295        } else {
9296            append_msi_property(&mut parsed.properties, &format_msi_property(token)?);
9297        }
9298        index += 1;
9299    }
9300
9301    Ok(parsed)
9302}
9303
9304#[cfg(any(windows, test))]
9305fn is_msi_switch(token: &str) -> bool {
9306    token.starts_with('/') || token.starts_with('-')
9307}
9308
9309#[cfg(any(windows, test))]
9310fn append_msi_property(properties: &mut String, property: &str) {
9311    if !properties.is_empty() {
9312        properties.push(' ');
9313    }
9314    properties.push_str(property);
9315}
9316
9317#[cfg(any(windows, test))]
9318fn format_msi_property(token: &str) -> Result<String> {
9319    let Some((name, value)) = token.split_once('=') else {
9320        bail!("invalid MSI property argument: {token}");
9321    };
9322    if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
9323        bail!("invalid MSI property name: {name}");
9324    }
9325    if value.contains(char::is_whitespace) && !(value.starts_with('"') && value.ends_with('"')) {
9326        Ok(format!("{name}=\"{}\"", value.replace('"', "\"\"")))
9327    } else {
9328        Ok(token.to_owned())
9329    }
9330}
9331
9332#[cfg(any(windows, test))]
9333fn parse_msi_ui_level(modifier: &str) -> Result<u32> {
9334    const INSTALLUILEVEL_NONE: u32 = 2;
9335    const INSTALLUILEVEL_BASIC: u32 = 3;
9336    const INSTALLUILEVEL_REDUCED: u32 = 4;
9337    const INSTALLUILEVEL_FULL: u32 = 5;
9338    const INSTALLUILEVEL_ENDDIALOG: u32 = 0x80;
9339    const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9340    const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9341    const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9342
9343    let modifier = if modifier.is_empty() { "n" } else { modifier };
9344    let mut chars = modifier.chars();
9345    let Some(first) = chars.next() else {
9346        return Ok(INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY);
9347    };
9348    let mut ui_level = match first.to_ascii_lowercase() {
9349        'f' => INSTALLUILEVEL_FULL,
9350        'r' => INSTALLUILEVEL_REDUCED,
9351        'b' => INSTALLUILEVEL_BASIC,
9352        'n' => INSTALLUILEVEL_NONE,
9353        '+' => INSTALLUILEVEL_NONE | INSTALLUILEVEL_ENDDIALOG,
9354        _ => bail!("invalid MSI /q modifier: {modifier}"),
9355    };
9356
9357    for ch in chars {
9358        match ch {
9359            '+' => ui_level |= INSTALLUILEVEL_ENDDIALOG,
9360            '-' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_PROGRESSONLY,
9361            '!' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_HIDECANCEL,
9362            '-' | '!' => bail!("MSI /q modifier {ch} is only valid with basic UI"),
9363            _ => bail!("invalid MSI /q modifier: {modifier}"),
9364        }
9365    }
9366
9367    if ui_level & 0xF == INSTALLUILEVEL_NONE {
9368        ui_level |= INSTALLUILEVEL_UACONLY;
9369    }
9370    Ok(ui_level)
9371}
9372
9373#[cfg(any(windows, test))]
9374#[allow(clippy::too_many_arguments)]
9375fn parse_msi_log_mode(
9376    modifier: &str,
9377    default_mode: u32,
9378    all_mode: u32,
9379    verbose: u32,
9380    extra_debug: u32,
9381    append: u32,
9382    flush: u32,
9383) -> Result<(u32, u32)> {
9384    let mut mode = 0;
9385    let mut attributes = 0;
9386    for ch in modifier.chars() {
9387        match ch.to_ascii_lowercase() {
9388            '*' => mode |= all_mode,
9389            'm' => mode |= 1,
9390            'e' => mode |= 2,
9391            'w' => mode |= 4,
9392            'u' => mode |= 8,
9393            'i' => mode |= 16,
9394            'o' => mode |= 128,
9395            'a' => mode |= 256,
9396            'r' => mode |= 512,
9397            'p' => mode |= 1024,
9398            'c' => mode |= 2048,
9399            'v' => mode |= verbose,
9400            'x' => mode |= extra_debug,
9401            '+' => attributes |= append,
9402            '!' => attributes |= flush,
9403            _ => bail!("invalid MSI /l modifier: {modifier}"),
9404        }
9405    }
9406    if mode == 0 {
9407        mode = default_mode;
9408    }
9409    Ok((mode, attributes))
9410}
9411
9412#[cfg(not(windows))]
9413fn dispatch_installer(
9414    _installer_path: &Path,
9415    _installer_type: &str,
9416    _request: &InstallRequest,
9417    _manifest: &Manifest,
9418    _installer: &Installer,
9419) -> Result<i32> {
9420    bail!("Installing packages is only supported on Windows")
9421}
9422
9423#[cfg(test)]
9424fn installer_command_arguments(
9425    installer_type: &str,
9426    request: &InstallRequest,
9427    _manifest: &Manifest,
9428    installer_path: &Path,
9429    installer: &Installer,
9430) -> Vec<String> {
9431    installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9432}
9433
9434#[cfg(any(windows, test))]
9435fn installer_direct_msi_arguments(
9436    installer_type: &str,
9437    request: &InstallRequest,
9438    _manifest: &Manifest,
9439    installer_path: &Path,
9440    installer: &Installer,
9441) -> Vec<String> {
9442    installer_command_arguments_with_options(installer_type, request, installer_path, installer, false)
9443}
9444
9445#[cfg(any(windows, test))]
9446fn installer_shell_execute_arguments(
9447    installer_type: &str,
9448    request: &InstallRequest,
9449    _manifest: &Manifest,
9450    installer_path: &Path,
9451    installer: &Installer,
9452) -> Vec<String> {
9453    installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9454}
9455
9456#[cfg(any(windows, test))]
9457fn installer_command_arguments_with_options(
9458    installer_type: &str,
9459    request: &InstallRequest,
9460    installer_path: &Path,
9461    installer: &Installer,
9462    include_msi_install_target: bool,
9463) -> Vec<String> {
9464    if let Some(override_args) = request.override_args.as_deref() {
9465        return split_installer_switches(override_args);
9466    }
9467
9468    let mut args = Vec::new();
9469    match installer_type {
9470        "msi" | "wix" if include_msi_install_target => {
9471            args.push("/i".to_owned());
9472            args.push(installer_path.display().to_string());
9473        }
9474        "msix" | "appx" | "zip" => return Vec::new(),
9475        _ => {}
9476    }
9477
9478    let configured = match request.mode {
9479        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
9480        InstallerMode::SilentWithProgress => installer
9481            .switches
9482            .silent_with_progress
9483            .as_deref()
9484            .or(installer.switches.silent.as_deref()),
9485        InstallerMode::Silent => installer
9486            .switches
9487            .silent
9488            .as_deref()
9489            .or(installer.switches.silent_with_progress.as_deref()),
9490    };
9491
9492    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
9493        args.extend(split_installer_switches(value));
9494    } else {
9495        args.extend(default_installer_arguments(installer_type, request.mode));
9496    }
9497
9498    append_switches(
9499        &mut args,
9500        resolve_template_switch(
9501            installer.switches.log.as_deref(),
9502            default_log_switch(installer_type),
9503            request.log_path.as_ref().map(|path| path.display().to_string()),
9504            "<LOGPATH>",
9505        ),
9506    );
9507    append_switches(&mut args, installer.switches.custom.clone());
9508    append_switches(&mut args, request.custom.clone());
9509    append_switches(
9510        &mut args,
9511        resolve_template_switch(
9512            installer.switches.install_location.as_deref(),
9513            default_install_location_switch(installer_type),
9514            request.install_location.clone(),
9515            "<INSTALLPATH>",
9516        ),
9517    );
9518    args
9519}
9520
9521#[cfg(any(windows, test))]
9522fn join_windows_arguments(args: &[String]) -> String {
9523    args.iter()
9524        .map(|arg| quote_windows_argument(arg))
9525        .collect::<Vec<_>>()
9526        .join(" ")
9527}
9528
9529#[cfg(any(windows, test))]
9530fn quote_windows_argument(arg: &str) -> String {
9531    if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') {
9532        return arg.to_owned();
9533    }
9534
9535    let mut quoted = String::from("\"");
9536    let mut backslashes = 0usize;
9537    for ch in arg.chars() {
9538        match ch {
9539            '\\' => backslashes += 1,
9540            '"' => {
9541                quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
9542                quoted.push('"');
9543                backslashes = 0;
9544            }
9545            _ => {
9546                quoted.push_str(&"\\".repeat(backslashes));
9547                backslashes = 0;
9548                quoted.push(ch);
9549            }
9550        }
9551    }
9552    quoted.push_str(&"\\".repeat(backslashes * 2));
9553    quoted.push('"');
9554    quoted
9555}
9556
9557#[cfg(any(windows, test))]
9558fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
9559    match mode {
9560        InstallerMode::Interactive => Vec::new(),
9561        InstallerMode::SilentWithProgress => match installer_type {
9562            "inno" => vec![
9563                "/SP-".to_owned(),
9564                "/SILENT".to_owned(),
9565                "/SUPPRESSMSGBOXES".to_owned(),
9566                "/NORESTART".to_owned(),
9567            ],
9568            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
9569            "nullsoft" | "nsis" => vec!["/S".to_owned()],
9570            _ => vec!["/SILENT".to_owned()],
9571        },
9572        InstallerMode::Silent => match installer_type {
9573            "inno" => vec![
9574                "/SP-".to_owned(),
9575                "/VERYSILENT".to_owned(),
9576                "/SUPPRESSMSGBOXES".to_owned(),
9577                "/NORESTART".to_owned(),
9578            ],
9579            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
9580            "nullsoft" | "nsis" => vec!["/S".to_owned()],
9581            _ => vec!["/S".to_owned()],
9582        },
9583    }
9584}
9585
9586#[cfg(any(windows, test))]
9587fn default_log_switch(installer_type: &str) -> Option<&'static str> {
9588    match installer_type {
9589        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
9590        "inno" => Some("/LOG=\"<LOGPATH>\""),
9591        _ => None,
9592    }
9593}
9594
9595#[cfg(any(windows, test))]
9596fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
9597    match installer_type {
9598        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
9599        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
9600        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
9601        _ => None,
9602    }
9603}
9604
9605#[cfg(any(windows, test))]
9606fn resolve_template_switch(
9607    manifest_value: Option<&str>,
9608    fallback: Option<&str>,
9609    replacement: Option<String>,
9610    token: &str,
9611) -> Option<String> {
9612    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
9613    let replacement = replacement?;
9614    Some(template.replace(token, &replacement))
9615}
9616
9617#[cfg(any(windows, test))]
9618fn append_switches(args: &mut Vec<String>, value: Option<String>) {
9619    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
9620        args.extend(split_installer_switches(&value));
9621    }
9622}
9623
9624#[cfg(any(windows, test))]
9625fn split_installer_switches(value: &str) -> Vec<String> {
9626    let mut args = Vec::new();
9627    let mut current = String::new();
9628    let mut in_quotes = false;
9629
9630    for ch in value.chars() {
9631        match ch {
9632            '"' => in_quotes = !in_quotes,
9633            c if c.is_whitespace() && !in_quotes => {
9634                if !current.is_empty() {
9635                    args.push(std::mem::take(&mut current));
9636                }
9637            }
9638            _ => current.push(ch),
9639        }
9640    }
9641
9642    if !current.is_empty() {
9643        args.push(current);
9644    }
9645
9646    args
9647}
9648
9649#[cfg(windows)]
9650fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
9651    if request.purge && request.preserve {
9652        bail!("--purge and --preserve cannot be used together.");
9653    }
9654
9655    if installed
9656        .installer_category
9657        .as_deref()
9658        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9659    {
9660        return uninstall_portable(installed, request);
9661    }
9662
9663    if (request.purge || request.preserve) && !request.force {
9664        bail!("--purge and --preserve are currently only supported for portable packages.");
9665    }
9666
9667    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
9668        return Ok(exit_code);
9669    }
9670
9671    if let Some(exit_code) = try_uninstall_msix(installed)? {
9672        return Ok(exit_code);
9673    }
9674
9675    bail!(
9676        "No uninstall command found for installed package '{}' ({})",
9677        installed.name,
9678        installed.local_id
9679    );
9680}
9681
9682#[cfg(windows)]
9683fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9684    use std::os::windows::process::CommandExt;
9685    use std::process::Command;
9686
9687    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
9688    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9689
9690    let arp_paths = [
9691        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
9692        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
9693    ];
9694
9695    for root in [&hklm, &hkcu] {
9696        for arp_path in &arp_paths {
9697            if let Ok(key) = root.open_subkey(arp_path) {
9698                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
9699                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
9700                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
9701                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
9702                        if !registry_entry_matches_installed_package(
9703                            &subkey_name,
9704                            &display_name,
9705                            product_code.as_deref(),
9706                            installed,
9707                        ) {
9708                            continue;
9709                        }
9710
9711                        if let Some(exit_code) =
9712                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
9713                        {
9714                            return Ok(Some(exit_code));
9715                        }
9716
9717                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
9718                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
9719                            subkey.get_value("UninstallString").ok()
9720                        } else {
9721                            quiet_uninstall_cmd
9722                                .clone()
9723                                .or_else(|| subkey.get_value("UninstallString").ok())
9724                        }
9725                        .context("No uninstall command found in registry")?;
9726
9727                        if is_winget_uninstall_command(&uninstall_cmd)
9728                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
9729                        {
9730                            return Ok(Some(exit_code));
9731                        }
9732
9733                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
9734                        let uninstall_line = build_uninstall_command_with_mode(
9735                            &uninstall_cmd,
9736                            request.mode,
9737                            quiet_uninstall_cmd.is_some(),
9738                            log_path.as_deref(),
9739                        );
9740                        // `UninstallString` is a complete command line and may already contain
9741                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
9742                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
9743                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
9744                        // rejects with "is not recognized as an internal or external command".
9745                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
9746                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
9747                        // automatic quoting so we can feed cmd the literal command line.
9748                        let mut cmd = Command::new("cmd");
9749                        cmd.raw_arg("/S /C");
9750                        cmd.raw_arg(format!("\"{uninstall_line}\""));
9751                        let status = cmd.status().context("failed to run uninstaller")?;
9752                        return Ok(Some(status.code().unwrap_or(-1)));
9753                    }
9754                }
9755            }
9756        }
9757    }
9758
9759    Ok(None)
9760}
9761
9762#[cfg(windows)]
9763fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
9764    use std::process::Command;
9765
9766    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
9767        return Ok(None);
9768    }
9769
9770    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
9771    let mut cmd = Command::new("powershell");
9772    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
9773        package_full_name,
9774        &installed.package_family_names,
9775    ));
9776    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
9777    Ok(Some(status.code().unwrap_or(-1)))
9778}
9779
9780#[cfg(windows)]
9781fn arp_subkey_name(local_id: &str) -> Option<&str> {
9782    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
9783}
9784
9785#[cfg(windows)]
9786fn registry_entry_matches_installed_package(
9787    subkey_name: &str,
9788    display_name: &str,
9789    product_code: Option<&str>,
9790    installed: &ListMatch,
9791) -> bool {
9792    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
9793        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
9794    {
9795        return true;
9796    }
9797
9798    if installed.product_codes.iter().any(|code| {
9799        code.eq_ignore_ascii_case(subkey_name)
9800            || product_code
9801                .map(|value| code.eq_ignore_ascii_case(value))
9802                .unwrap_or(false)
9803    }) {
9804        return true;
9805    }
9806
9807    display_name.eq_ignore_ascii_case(&installed.name)
9808}
9809
9810#[cfg(all(windows, test))]
9811fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
9812    build_uninstall_command_with_mode(
9813        uninstall_cmd,
9814        if silent {
9815            InstallerMode::Silent
9816        } else {
9817            InstallerMode::Interactive
9818        },
9819        has_quiet_command,
9820        None,
9821    )
9822}
9823
9824#[cfg(windows)]
9825fn build_uninstall_command_with_mode(
9826    uninstall_cmd: &str,
9827    mode: InstallerMode,
9828    has_quiet_command: bool,
9829    log_path: Option<&str>,
9830) -> String {
9831    let uninstall_cmd = log_path
9832        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
9833        .unwrap_or_else(|| uninstall_cmd.to_owned());
9834    if mode == InstallerMode::Interactive || has_quiet_command {
9835        return uninstall_cmd;
9836    }
9837
9838    let lower = uninstall_cmd.to_ascii_lowercase();
9839    if is_winget_uninstall_command_lower(&lower) {
9840        return uninstall_cmd;
9841    }
9842    if lower.contains("/quiet")
9843        || lower.contains("/passive")
9844        || lower.contains("/verysilent")
9845        || lower.contains("/silent")
9846        || lower.contains(" /s")
9847    {
9848        return uninstall_cmd;
9849    }
9850
9851    format!("{uninstall_cmd} /S")
9852}
9853
9854#[cfg(windows)]
9855fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9856    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
9857        return Ok(None);
9858    };
9859
9860    let exit_code = run_winget_uninstall(&args)?;
9861    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
9862        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
9863            return Ok(Some(exit_code));
9864        };
9865
9866        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
9867    }
9868
9869    Ok(Some(exit_code))
9870}
9871
9872#[cfg(windows)]
9873fn run_winget_uninstall(args: &[String]) -> Result<i32> {
9874    use std::process::Command;
9875
9876    let status = Command::new("winget")
9877        .args(args)
9878        .status()
9879        .context("failed to run delegated winget uninstall")?;
9880    Ok(status.code().unwrap_or(-1))
9881}
9882
9883#[cfg(windows)]
9884fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
9885    build_winget_uninstall_arguments_with_scope(installed, request, true)
9886}
9887
9888#[cfg(windows)]
9889fn build_winget_uninstall_arguments_with_scope(
9890    installed: &ListMatch,
9891    request: &UninstallRequest,
9892    include_scope: bool,
9893) -> Option<Vec<String>> {
9894    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
9895        return None;
9896    }
9897
9898    let mut args = vec![
9899        "uninstall".to_owned(),
9900        "--id".to_owned(),
9901        installed.id.clone(),
9902        "--exact".to_owned(),
9903        "--disable-interactivity".to_owned(),
9904    ];
9905
9906    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
9907        args.push("--source".to_owned());
9908        args.push(source_name.to_owned());
9909    }
9910
9911    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
9912        args.push("--scope".to_owned());
9913        args.push(scope.to_owned());
9914    }
9915
9916    if request.mode == InstallerMode::Silent {
9917        args.push("--silent".to_owned());
9918    }
9919
9920    Some(args)
9921}
9922
9923#[cfg(windows)]
9924fn is_winget_uninstall_command(command: &str) -> bool {
9925    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
9926}
9927
9928#[cfg(windows)]
9929fn is_winget_uninstall_command_lower(command: &str) -> bool {
9930    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
9931}
9932
9933#[cfg(windows)]
9934fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
9935    let full_name_literal = package_full_name
9936        .map(|value| format!("'{}'", value.replace('\'', "''")))
9937        .unwrap_or_else(|| "$null".to_owned());
9938    let family_names_literal = if package_family_names.is_empty() {
9939        "@()".to_owned()
9940    } else {
9941        format!(
9942            "@({})",
9943            package_family_names
9944                .iter()
9945                .map(|value| format!("'{}'", value.replace('\'', "''")))
9946                .collect::<Vec<_>>()
9947                .join(", ")
9948        )
9949    };
9950
9951    format!(
9952        "$fullName = {full_name_literal}; \
9953         $familyNames = {family_names_literal}; \
9954         $targets = Get-AppxPackage | Where-Object {{ \
9955         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
9956         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
9957         if (-not $targets) {{ exit 1 }}; \
9958         $targets | Remove-AppxPackage"
9959    )
9960}
9961
9962#[cfg(windows)]
9963fn try_run_msi_uninstall(
9964    installed: &ListMatch,
9965    subkey_name: &str,
9966    product_code: Option<&str>,
9967    request: &UninstallRequest,
9968) -> Result<Option<i32>> {
9969    use std::process::Command;
9970
9971    let uninstall_code = installed
9972        .product_codes
9973        .iter()
9974        .map(String::as_str)
9975        .chain(product_code)
9976        .chain(Some(subkey_name))
9977        .find(|value| is_product_code_like(value));
9978
9979    let Some(uninstall_code) = uninstall_code else {
9980        return Ok(None);
9981    };
9982
9983    let mut cmd = Command::new("msiexec");
9984    cmd.arg("/x").arg(uninstall_code);
9985    match request.mode {
9986        InstallerMode::Interactive => {}
9987        InstallerMode::SilentWithProgress => {
9988            cmd.arg("/passive").arg("/norestart");
9989        }
9990        InstallerMode::Silent => {
9991            cmd.arg("/quiet").arg("/norestart");
9992        }
9993    }
9994    if let Some(log_path) = &request.log_path {
9995        cmd.arg("/log").arg(log_path);
9996    }
9997
9998    let status = cmd.status().context("failed to run msiexec uninstall")?;
9999    Ok(Some(status.code().unwrap_or(-1)))
10000}
10001
10002#[cfg(windows)]
10003fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
10004    // Read PortableSymlinkFullPath from the ARP entry *before* we touch the
10005    // registry, so we can remove winget-created shims in Links\ if any. Without
10006    // this, a portable that winget originally installed (with a
10007    // PortableCommandAlias set in its manifest) would leave a dangling symlink
10008    // in %LOCALAPPDATA%\Microsoft\WinGet\Links\ once pinget uninstalls it.
10009    let known_symlink = read_portable_symlink_full_path(installed);
10010
10011    let Some(location) = installed.install_location.as_deref() else {
10012        if request.force {
10013            try_remove_portable_symlinks(installed, &known_symlink, None);
10014            try_remove_portable_registry_entry(installed);
10015            return Ok(0);
10016        }
10017        bail!(
10018            "Portable package '{}' does not expose an install location.",
10019            installed.name
10020        );
10021    };
10022
10023    if request.preserve {
10024        return Ok(0);
10025    }
10026
10027    let path = Path::new(location);
10028    let removed = if path.is_dir() {
10029        fs::remove_dir_all(path)?;
10030        true
10031    } else if path.is_file() {
10032        fs::remove_file(path)?;
10033        true
10034    } else {
10035        false
10036    };
10037
10038    if removed || request.force {
10039        // Once the files are gone (or the user forced past missing files), drop
10040        // the symlink shim and ARP entry too so the package fully disappears.
10041        try_remove_portable_symlinks(installed, &known_symlink, Some(path));
10042        try_remove_portable_registry_entry(installed);
10043        return Ok(0);
10044    }
10045
10046    bail!("Portable package location not found: {location}")
10047}
10048
10049/// Reads `PortableSymlinkFullPath` from the ARP subkey backing `installed`
10050/// when present. This value is recorded for portable installs that created a
10051/// `Links\<alias>.exe` symlink, including pinget-installed entries when
10052/// `install_portable` created that link. Entries without a created/recorded
10053/// portable symlink return `None`.
10054#[cfg(windows)]
10055fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
10056    const ARP_PREFIX: &str = r"ARP\";
10057    if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
10058        let mut parts = rest.splitn(3, '\\');
10059        let scope = parts.next().unwrap_or("");
10060        let _arch = parts.next();
10061        let subkey_name = parts.next().unwrap_or("");
10062        if subkey_name.is_empty() {
10063            return None;
10064        }
10065        let hive = if scope.eq_ignore_ascii_case("User") {
10066            RegKey::predef(HKEY_CURRENT_USER)
10067        } else {
10068            RegKey::predef(HKEY_LOCAL_MACHINE)
10069        };
10070        let subkey = hive
10071            .open_subkey(format!(
10072                r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
10073            ))
10074            .ok()?;
10075        return read_reg_string(&subkey, "PortableSymlinkFullPath");
10076    }
10077    None
10078}
10079
10080/// Removes the symlink shims winget would have created for this portable.
10081/// Tries (1) the exact path from `PortableSymlinkFullPath` if known, and
10082/// (2) any symlink in `%LOCALAPPDATA%\Microsoft\WinGet\Links\` whose target
10083/// resolves under `install_location` (defensive sweep for shims winget made
10084/// but didn't surface in the registry). Best-effort: failures are swallowed.
10085#[cfg(windows)]
10086fn try_remove_portable_symlinks(
10087    _installed: &ListMatch,
10088    known_symlink: &Option<String>,
10089    install_location: Option<&Path>,
10090) {
10091    if let Some(path) = known_symlink.as_deref() {
10092        let _ = fs::remove_file(path);
10093    }
10094
10095    // Defensive sweep: walk Links\ and remove shims whose stored target was
10096    // inside (or equal to) install_location. By this point install_location is
10097    // already deleted, so we compare the symlink's stored target string (which
10098    // the filesystem retains even after the target itself is gone).
10099    let Some(install_dir) = install_location else { return };
10100
10101    let Some(local) = dirs::data_local_dir() else { return };
10102    let links_root = local.join("Microsoft").join("WinGet").join("Links");
10103    let Ok(entries) = fs::read_dir(&links_root) else { return };
10104    for entry in entries.flatten() {
10105        let path = entry.path();
10106        let Ok(target) = fs::read_link(&path) else { continue };
10107        if path_starts_with_case_insensitive(&target, install_dir) {
10108            let _ = fs::remove_file(&path);
10109        }
10110    }
10111}
10112
10113/// Case-insensitive, component-aware path-prefix check for Windows. A raw
10114/// `str::starts_with` on the path strings would let `C:\Foo` falsely match
10115/// `C:\Foobar`; iterating path components avoids that. Used by the Links\
10116/// sweep so an uninstall of one portable can't accidentally delete shims that
10117/// belong to a different portable whose install dir happens to share a
10118/// string-prefix.
10119#[cfg(windows)]
10120fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
10121    let lower_components = |p: &Path| -> Vec<String> {
10122        p.components()
10123            .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
10124            .collect()
10125    };
10126    let parent_components = lower_components(parent);
10127    let child_components = lower_components(child);
10128    if parent_components.is_empty() || parent_components.len() > child_components.len() {
10129        return false;
10130    }
10131    parent_components
10132        .iter()
10133        .zip(child_components.iter())
10134        .all(|(a, b)| a == b)
10135}
10136
10137/// Best-effort removal of the HKCU/HKLM ARP subkey backing a portable entry.
10138/// We match on local_id (`ARP\<scope>\<arch>\<subkey>`) when present so we
10139/// delete the exact key the list view surfaced; otherwise we walk the standard
10140/// Uninstall path and match on WinGetPackageIdentifier. Failures are logged via
10141/// the returned warning chain in callers — we never bubble a registry-cleanup
10142/// failure as a hard error because the file deletion already succeeded.
10143#[cfg(windows)]
10144fn try_remove_portable_registry_entry(installed: &ListMatch) {
10145    // Collect (is_hkcu, subkey_path) pairs so we can re-open hives at delete
10146    // time instead of holding clones of RegKey (which doesn't implement Clone).
10147    let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
10148        // local_id format is `ARP\<scope>\<arch>\<subkey>` — pick the right hive/path.
10149        let mut parts = rest.splitn(3, '\\');
10150        let scope = parts.next().unwrap_or("");
10151        let _arch = parts.next();
10152        let subkey_name = parts.next().unwrap_or("");
10153        if subkey_name.is_empty() {
10154            return;
10155        }
10156        let is_hkcu = scope.eq_ignore_ascii_case("User");
10157        vec![(
10158            is_hkcu,
10159            format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
10160        )]
10161    } else {
10162        // Fall back to scanning by WinGetPackageIdentifier.
10163        let mut found = Vec::new();
10164        for (is_hkcu, hive) in [
10165            (true, RegKey::predef(HKEY_CURRENT_USER)),
10166            (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
10167        ] {
10168            let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
10169                continue;
10170            };
10171            for name in uninstall.enum_keys().flatten() {
10172                let Ok(subkey) = uninstall.open_subkey(&name) else {
10173                    continue;
10174                };
10175                if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
10176                    found.push((
10177                        is_hkcu,
10178                        format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
10179                    ));
10180                }
10181            }
10182        }
10183        found
10184    };
10185
10186    for (is_hkcu, path) in candidates {
10187        let hive = if is_hkcu {
10188            RegKey::predef(HKEY_CURRENT_USER)
10189        } else {
10190            RegKey::predef(HKEY_LOCAL_MACHINE)
10191        };
10192        let _ = hive.delete_subkey_all(&path);
10193    }
10194}
10195
10196#[cfg(windows)]
10197fn is_product_code_like(value: &str) -> bool {
10198    value.starts_with('{') && value.ends_with('}')
10199}
10200
10201#[cfg(not(windows))]
10202fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
10203    bail!("Uninstalling packages is only supported on Windows")
10204}
10205
10206/// Installs (or upgrades) a portable WinGet package.
10207///
10208/// Handles both `InstallerType: portable` (single binary download) and
10209/// `InstallerType: zip` with `NestedInstallerType: portable` (zip containing
10210/// the binary at a relative path), which is the shape every portable in the
10211/// community winget catalog uses today.
10212///
10213/// Behavior matches winget's portable workflow closely enough that the resulting
10214/// HKCU ARP entry is recognized by `winget list` and by pinget's own list view:
10215/// - Resolves the target install directory in this priority order:
10216///   1. `request.install_location` if provided (passed for upgrades)
10217///   2. Existing `InstallLocation` from the registry ARP entry (so upgrades
10218///      with no explicit location still preserve the user's custom path)
10219///   3. WinGet's default user portable root, `%LOCALAPPDATA%\Microsoft\WinGet
10220///      \Packages\<PackageId>_<SourceId>`
10221/// - If an existing pinget-owned install lives in that directory
10222///   (`InstallDirectoryCreated=1`), cleans its contents before extracting the
10223///   new version so leftover files from older versions don't pile up.
10224/// - Extracts the zip (or copies the standalone binary), then writes the ARP
10225///   registry entry that `winget list` and pinget's `collect_uninstall_view`
10226///   both read from.
10227#[cfg(windows)]
10228fn install_portable(
10229    installer_path: &Path,
10230    request: &InstallRequest,
10231    manifest: &Manifest,
10232    installer: &Installer,
10233) -> Result<i32> {
10234    let source_identifier = portable_source_identifier(&request.query);
10235    let existing_entry = read_existing_portable_entry(&manifest.id);
10236    // Reuse the existing subkey name on upgrade so we don't orphan the prior
10237    // entry by writing to a different one.
10238    let subkey_name = existing_entry
10239        .as_ref()
10240        .map(|entry| entry.subkey_name.clone())
10241        .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
10242
10243    let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
10244
10245    // If we created this directory on a previous install, wipe it clean so the
10246    // new version doesn't co-exist with leftovers from the previous one. We do
10247    // not touch a directory we don't own (InstallDirectoryCreated != 1), to
10248    // avoid stomping user data.
10249    let prev_dir_created = existing_entry
10250        .as_ref()
10251        .and_then(|entry| entry.install_directory_created)
10252        == Some(1);
10253    let dir_existed = target_dir.is_dir();
10254    if prev_dir_created && dir_existed {
10255        clean_directory_contents(&target_dir).with_context(|| {
10256            format!(
10257                "failed to clean existing portable install directory: {}",
10258                target_dir.display()
10259            )
10260        })?;
10261    }
10262
10263    fs::create_dir_all(&target_dir)
10264        .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
10265    // The "we created it" flag stays sticky across upgrades — if a previous
10266    // install marked it created, it still counts as created by us even when
10267    // the dir already existed this round.
10268    let install_directory_created = !dir_existed || prev_dir_created;
10269
10270    let installer_type = installer.installer_type.as_deref().unwrap_or("");
10271    let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
10272        let file = fs::File::open(installer_path)
10273            .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
10274        let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
10275        archive
10276            .extract(&target_dir)
10277            .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
10278        // For nested-portable zips the binary lives at the RelativeFilePath the
10279        // manifest declares. We record the first one as PortableTargetFullPath so
10280        // winget's portable uninstall workflow can identify which file to remove.
10281        installer
10282            .nested_installer_files
10283            .first()
10284            .map(|file| target_dir.join(&file.relative_file_path))
10285            .unwrap_or_else(|| target_dir.clone())
10286    } else {
10287        // Standalone portable: copy the downloaded file into target_dir using
10288        // its original filename. winget would optionally rename to the
10289        // PortableCommandAlias; pinget keeps the original name for now.
10290        let basename = installer_path
10291            .file_name()
10292            .ok_or_else(|| anyhow!("portable installer has no filename"))?;
10293        let copied = target_dir.join(basename);
10294        fs::copy(installer_path, &copied)
10295            .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
10296        copied
10297    };
10298
10299    // Create the Links\<alias>.exe shim so users can invoke the portable from
10300    // any shell, matching winget's portable workflow. Failures are non-fatal —
10301    // when the user lacks SeCreateSymbolicLink (no Developer Mode, not admin)
10302    // we still leave the package installed at install_location.
10303    let alias = determine_portable_alias(installer);
10304    let symlink_full_path = match alias.as_deref() {
10305        Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
10306        _ => None,
10307    };
10308
10309    // Ensure %LOCALAPPDATA%\Microsoft\WinGet\Links is on user PATH so the
10310    // symlink we just created is resolvable from new shells. Tracks whether we
10311    // added it this round so uninstall can decide whether to take it back out.
10312    let added_to_path = if symlink_full_path.is_some() {
10313        try_add_links_dir_to_user_path()
10314    } else {
10315        false
10316    };
10317
10318    write_portable_arp_entry(&PortableArpEntry {
10319        subkey_name: &subkey_name,
10320        install_location: &target_dir,
10321        portable_target_full_path: &portable_target_full_path,
10322        portable_symlink_full_path: symlink_full_path.as_deref(),
10323        install_directory_created,
10324        added_to_path,
10325        source_identifier: &source_identifier,
10326        manifest,
10327    })?;
10328
10329    Ok(0)
10330}
10331
10332#[cfg(windows)]
10333struct PortableArpEntry<'a> {
10334    subkey_name: &'a str,
10335    install_location: &'a Path,
10336    portable_target_full_path: &'a Path,
10337    portable_symlink_full_path: Option<&'a Path>,
10338    install_directory_created: bool,
10339    added_to_path: bool,
10340    source_identifier: &'a str,
10341    manifest: &'a Manifest,
10342}
10343
10344/// Picks the portable command alias from the manifest. Mirrors winget's
10345/// resolution: nested-installer files first (used by zip+portable manifests),
10346/// then the top-level `Commands` field (used by standalone portable manifests).
10347/// Returns None when the manifest doesn't declare a command — the package
10348/// still installs, just without a Links\ shim.
10349#[cfg(windows)]
10350fn determine_portable_alias(installer: &Installer) -> Option<String> {
10351    installer
10352        .nested_installer_files
10353        .iter()
10354        .find_map(|file| file.portable_command_alias.clone())
10355        .or_else(|| installer.commands.first().cloned())
10356}
10357
10358#[cfg(windows)]
10359fn winget_links_dir() -> PathBuf {
10360    dirs::data_local_dir()
10361        .unwrap_or_else(|| PathBuf::from("."))
10362        .join("Microsoft")
10363        .join("WinGet")
10364        .join("Links")
10365}
10366
10367/// Creates (or replaces) `Links\<alias>.exe` as a file symlink pointing at the
10368/// portable's binary. Returns the link path on success. Returns None and logs
10369/// a warning if the user can't create symlinks (no Developer Mode, not admin)
10370/// so the install can still complete without the shim.
10371#[cfg(windows)]
10372fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
10373    use std::os::windows::fs::symlink_file;
10374    let links_dir = winget_links_dir();
10375    if fs::create_dir_all(&links_dir).is_err() {
10376        return None;
10377    }
10378    let link_path = links_dir.join(format!("{alias}.exe"));
10379
10380    // Replace any prior link/file at the path so upgrades repoint to the new
10381    // binary instead of leaving a stale symlink behind.
10382    if link_path.symlink_metadata().is_ok() {
10383        let _ = fs::remove_file(&link_path);
10384    }
10385
10386    // Symlink creation fails with ERROR_PRIVILEGE_NOT_HELD (1314) when the
10387    // process lacks SeCreateSymbolicLink (no Developer Mode, not admin). Treat
10388    // that — and any other I/O failure — as non-fatal: the package files are
10389    // already in place, just without a Links\ shim.
10390    symlink_file(target, &link_path).ok().map(|()| link_path)
10391}
10392
10393/// Adds `Links\` to the user's HKCU\Environment\Path if not already present,
10394/// then broadcasts WM_SETTINGCHANGE so explorer-spawned shells pick up the
10395/// new PATH without a logoff. Returns true if we actually appended PATH this
10396/// call — callers should write `InstallDirectoryAddedToPath` accordingly so a
10397/// follow-up uninstall doesn't take PATH back out for other portables.
10398#[cfg(windows)]
10399fn try_add_links_dir_to_user_path() -> bool {
10400    let links_dir = winget_links_dir();
10401    let links_str = links_dir.to_string_lossy().to_string();
10402    let normalized_links = links_str.to_ascii_lowercase();
10403
10404    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10405    let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
10406        return false;
10407    };
10408    let existing: String = env_key.get_value("Path").unwrap_or_default();
10409    let already_present = existing
10410        .split(';')
10411        .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
10412    if already_present {
10413        return false;
10414    }
10415
10416    let new_path = if existing.is_empty() {
10417        links_str
10418    } else if existing.ends_with(';') {
10419        format!("{existing}{links_str}")
10420    } else {
10421        format!("{existing};{links_str}")
10422    };
10423
10424    if env_key.set_value("Path", &new_path).is_err() {
10425        return false;
10426    }
10427
10428    broadcast_environment_change();
10429    true
10430}
10431
10432#[cfg(windows)]
10433fn broadcast_environment_change() {
10434    use std::ffi::OsStr;
10435    use std::os::windows::ffi::OsStrExt;
10436
10437    use windows_sys::Win32::UI::WindowsAndMessaging::{
10438        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
10439    };
10440
10441    let param: Vec<u16> = OsStr::new("Environment")
10442        .encode_wide()
10443        .chain(std::iter::once(0))
10444        .collect();
10445    // SAFETY: SendMessageTimeoutW only reads `param` for the duration of the
10446    // call (it copies the string before returning), and `result` is a stack
10447    // local we provide. No aliasing or lifetime issues.
10448    unsafe {
10449        let mut result: usize = 0;
10450        SendMessageTimeoutW(
10451            HWND_BROADCAST,
10452            WM_SETTINGCHANGE,
10453            0usize,
10454            param.as_ptr() as isize,
10455            SMTO_ABORTIFHUNG,
10456            5000,
10457            &mut result,
10458        );
10459    }
10460}
10461
10462/// Source identifier embedded in the ARP subkey name. We mirror winget for
10463/// the community repo so winget's UninstallString can resolve the subkey and
10464/// `winget list` still finds the package; everything else uses the source
10465/// name verbatim.
10466#[cfg(windows)]
10467fn portable_source_identifier(query: &PackageQuery) -> String {
10468    match query.source.as_deref() {
10469        Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10470        Some(name) if !name.is_empty() => name.to_owned(),
10471        _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10472    }
10473}
10474
10475#[cfg(windows)]
10476fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
10477    format!("{package_id}_{source_identifier}")
10478}
10479
10480#[cfg(windows)]
10481struct ExistingPortableEntry {
10482    install_location: Option<PathBuf>,
10483    install_directory_created: Option<u32>,
10484    subkey_name: String,
10485}
10486
10487#[cfg(windows)]
10488fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
10489    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10490    let uninstall = hkcu
10491        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
10492        .ok()?;
10493    for name in uninstall.enum_keys().flatten() {
10494        let Ok(subkey) = uninstall.open_subkey(&name) else {
10495            continue;
10496        };
10497        if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
10498            continue;
10499        }
10500        if !read_reg_string(&subkey, "WinGetInstallerType")
10501            .as_deref()
10502            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
10503        {
10504            continue;
10505        }
10506        return Some(ExistingPortableEntry {
10507            install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
10508            install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
10509            subkey_name: name,
10510        });
10511    }
10512    None
10513}
10514
10515#[cfg(windows)]
10516fn resolve_portable_install_location(
10517    request: &InstallRequest,
10518    existing: &Option<ExistingPortableEntry>,
10519    subkey_name: &str,
10520) -> PathBuf {
10521    resolve_portable_install_location_pure(
10522        request.install_location.as_deref(),
10523        existing
10524            .as_ref()
10525            .and_then(|e| e.install_location.as_deref())
10526            .and_then(Path::to_str),
10527        subkey_name,
10528        &default_user_portable_root(),
10529    )
10530}
10531
10532/// Pure resolution rule for the portable install directory. Priority:
10533/// `request_location` (CLI override) → `existing_location` (so upgrades
10534/// preserve a winget-installed location) → default user portable root joined
10535/// with the package's ARP subkey name.
10536#[cfg(windows)]
10537fn resolve_portable_install_location_pure(
10538    request_location: Option<&str>,
10539    existing_location: Option<&str>,
10540    subkey_name: &str,
10541    default_user_portable_root: &Path,
10542) -> PathBuf {
10543    if let Some(custom) = request_location
10544        && !custom.is_empty()
10545    {
10546        return PathBuf::from(custom);
10547    }
10548    if let Some(loc) = existing_location
10549        && !loc.is_empty()
10550    {
10551        return PathBuf::from(loc);
10552    }
10553    default_user_portable_root.join(subkey_name)
10554}
10555
10556#[cfg(windows)]
10557fn default_user_portable_root() -> PathBuf {
10558    dirs::data_local_dir()
10559        .unwrap_or_else(|| PathBuf::from("."))
10560        .join("Microsoft")
10561        .join("WinGet")
10562        .join("Packages")
10563}
10564
10565#[cfg(windows)]
10566fn clean_directory_contents(dir: &Path) -> Result<()> {
10567    if !dir.is_dir() {
10568        return Ok(());
10569    }
10570    for entry in fs::read_dir(dir)? {
10571        let entry = entry?;
10572        let path = entry.path();
10573        if path.is_dir() {
10574            fs::remove_dir_all(&path)?;
10575        } else {
10576            fs::remove_file(&path)?;
10577        }
10578    }
10579    Ok(())
10580}
10581
10582/// Writes the HKCU ARP entry winget would normally write for a portable
10583/// install. Only the subset that winget's portable list view and pinget's
10584/// `collect_uninstall_view` actually read.
10585#[cfg(windows)]
10586fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
10587    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10588    let uninstall_path = format!(
10589        r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
10590        entry.subkey_name
10591    );
10592    let (subkey, _) = hkcu
10593        .create_subkey(&uninstall_path)
10594        .context("failed to create portable ARP registry subkey")?;
10595
10596    let manifest = entry.manifest;
10597    let install_location_str = entry.install_location.to_string_lossy().to_string();
10598    subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
10599    subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
10600    subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
10601    subkey.set_value("InstallLocation", &install_location_str)?;
10602    subkey.set_value(
10603        "PortableTargetFullPath",
10604        &entry.portable_target_full_path.to_string_lossy().to_string(),
10605    )?;
10606    if let Some(symlink_path) = entry.portable_symlink_full_path {
10607        subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
10608    }
10609    subkey.set_value(
10610        "InstallDirectoryAddedToPath",
10611        &(if entry.added_to_path { 1u32 } else { 0u32 }),
10612    )?;
10613    subkey.set_value(
10614        "InstallDirectoryCreated",
10615        &(if entry.install_directory_created { 1u32 } else { 0u32 }),
10616    )?;
10617    subkey.set_value(
10618        "DisplayName",
10619        &if manifest.name.is_empty() {
10620            manifest.id.clone()
10621        } else {
10622            manifest.name.clone()
10623        },
10624    )?;
10625    subkey.set_value("DisplayVersion", &manifest.version)?;
10626    if let Some(publisher) = manifest.publisher.as_ref() {
10627        subkey.set_value("Publisher", publisher)?;
10628    }
10629    subkey.set_value(
10630        "UninstallString",
10631        &format!("winget uninstall --product-code {}", entry.subkey_name),
10632    )?;
10633    let today = Utc::now().format("%Y%m%d").to_string();
10634    subkey.set_value("InstallDate", &today)?;
10635    if let Some(url) = manifest.package_url.as_ref() {
10636        subkey.set_value("URLInfoAbout", url)?;
10637    }
10638
10639    Ok(())
10640}
10641
10642#[cfg(test)]
10643mod tests {
10644    use std::io::{Read as _, Write};
10645    use std::net::{SocketAddr, TcpListener, TcpStream};
10646    use std::path::{Path, PathBuf};
10647    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
10648    use std::thread::{self, JoinHandle};
10649
10650    use flate2::Compression;
10651    use flate2::write::DeflateEncoder;
10652    use zip::write::SimpleFileOptions;
10653
10654    use super::*;
10655
10656    fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
10657        Manifest {
10658            id: id.to_owned(),
10659            name: name.to_owned(),
10660            version: version.to_owned(),
10661            channel: String::new(),
10662            publisher: Some("Acme".to_owned()),
10663            description: Some("desc".to_owned()),
10664            moniker: None,
10665            package_url: None,
10666            publisher_url: None,
10667            publisher_support_url: None,
10668            license: None,
10669            license_url: None,
10670            privacy_url: None,
10671            author: None,
10672            copyright: None,
10673            copyright_url: None,
10674            release_notes: None,
10675            release_notes_url: None,
10676            tags: Vec::new(),
10677            agreements: Vec::new(),
10678            package_dependencies: Vec::new(),
10679            documentation: Vec::new(),
10680            icons: Vec::new(),
10681            installers: Vec::new(),
10682            require_explicit_upgrade: false,
10683        }
10684    }
10685
10686    #[test]
10687    fn derive_download_filename_matches_winget_pattern() {
10688        let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
10689        let installer = Installer {
10690            architecture: Some("arm64".to_owned()),
10691            installer_type: Some("exe".to_owned()),
10692            scope: Some("user".to_owned()),
10693            locale: Some("en-US".to_owned()),
10694            ..Installer::default()
10695        };
10696        assert_eq!(
10697            derive_download_manifest_filename(&manifest, &installer),
10698            "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
10699        );
10700    }
10701
10702    #[test]
10703    fn derive_download_filename_omits_optional_components() {
10704        let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
10705        let installer = Installer {
10706            architecture: Some("x64".to_owned()),
10707            installer_type: Some("exe".to_owned()),
10708            ..Installer::default()
10709        };
10710        // No scope, no locale → both omitted; architecture remains PascalCase.
10711        assert_eq!(
10712            derive_download_manifest_filename(&manifest, &installer),
10713            "7-Zip_26.01_X64_exe.yaml"
10714        );
10715    }
10716
10717    #[test]
10718    fn derive_download_filename_skips_unknown_version() {
10719        let manifest = sample_manifest("Test.Package", "Unknown", "Test");
10720        let installer = Installer {
10721            architecture: Some("neutral".to_owned()),
10722            installer_type: Some("msi".to_owned()),
10723            ..Installer::default()
10724        };
10725        assert_eq!(
10726            derive_download_manifest_filename(&manifest, &installer),
10727            "Test_Neutral_msi.yaml"
10728        );
10729    }
10730
10731    #[test]
10732    fn derive_download_filename_sanitizes_illegal_chars() {
10733        let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
10734        let installer = Installer {
10735            architecture: Some("x64".to_owned()),
10736            installer_type: Some("exe".to_owned()),
10737            ..Installer::default()
10738        };
10739        // `/` and `:` are illegal in Windows filenames; both must be replaced.
10740        let filename = derive_download_manifest_filename(&manifest, &installer);
10741        assert!(!filename.contains('/') && !filename.contains(':'));
10742        assert!(filename.starts_with("Some_Bad_Name_1.0"));
10743    }
10744
10745    #[test]
10746    fn build_download_manifest_yaml_overrides_identity_fields() {
10747        let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10748        // Documents contain a stale PackageVersion (e.g. empty) — the typed
10749        // manifest's corrected version must win.
10750        let documents = serde_json::json!({
10751            "PackageIdentifier": "stale-id",
10752            "PackageVersion": "",
10753            "PackageName": "Old Name",
10754            "Channel": "preview",
10755            "Installers": [
10756                { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
10757                { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
10758            ],
10759        });
10760        let installer = Installer {
10761            architecture: Some("x64".to_owned()),
10762            installer_type: Some("exe".to_owned()),
10763            url: Some("https://example.com/a".to_owned()),
10764            ..Installer::default()
10765        };
10766        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10767        assert!(yaml.contains("PackageIdentifier: App.Id"));
10768        assert!(yaml.contains("PackageVersion: 2.0.0"));
10769        assert!(yaml.contains("PackageName: App Name"));
10770        assert!(yaml.contains("ManifestType: merged"));
10771        // Channel had a value in docs but the typed manifest's channel is empty
10772        // → expect it stripped, since winget download manifests don't carry it.
10773        assert!(!yaml.contains("Channel: preview"));
10774        // Installers array narrows to the single selected installer.
10775        assert_eq!(yaml.matches("InstallerUrl").count(), 1);
10776        assert!(yaml.contains("https://example.com/a"));
10777        assert!(!yaml.contains("https://example.com/b"));
10778    }
10779
10780    #[test]
10781    fn build_download_manifest_yaml_preserves_typed_channel() {
10782        let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10783        manifest.channel = "beta".to_owned();
10784        let documents = serde_json::json!({
10785            "PackageIdentifier": "App.Id",
10786            "PackageVersion": "2.0.0",
10787            "Installers": [],
10788        });
10789        let installer = Installer::default();
10790        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10791        assert!(yaml.contains("Channel: beta"));
10792    }
10793
10794    fn temp_app_root(label: &str) -> PathBuf {
10795        static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
10796        std::env::temp_dir().join(format!(
10797            "pinget-rs-tests-{label}-{}-{}",
10798            std::process::id(),
10799            TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
10800        ))
10801    }
10802
10803    #[cfg(windows)]
10804    #[test]
10805    fn path_prefix_check_rejects_string_prefix_collisions() {
10806        // Regression: a naive str::starts_with would falsely consider
10807        // C:\Foobar a child of C:\Foo and let the Links\ sweep delete unrelated
10808        // symlinks. The component-aware check must reject these.
10809        assert!(!path_starts_with_case_insensitive(
10810            Path::new(r"C:\Foobar\bin.exe"),
10811            Path::new(r"C:\Foo"),
10812        ));
10813        assert!(!path_starts_with_case_insensitive(
10814            Path::new(r"C:\Foo\bin.exe.bak"),
10815            Path::new(r"C:\Foo\bin.exe"),
10816        ));
10817    }
10818
10819    #[cfg(windows)]
10820    #[test]
10821    fn path_prefix_check_accepts_real_children_and_self() {
10822        // Dir install: the symlink target lives inside the install dir.
10823        assert!(path_starts_with_case_insensitive(
10824            Path::new(r"C:\Foo\sub\bin.exe"),
10825            Path::new(r"C:\Foo"),
10826        ));
10827        // File install: the symlink points at the same file we removed.
10828        assert!(path_starts_with_case_insensitive(
10829            Path::new(r"C:\Foo\bin.exe"),
10830            Path::new(r"C:\Foo\bin.exe"),
10831        ));
10832    }
10833
10834    #[cfg(windows)]
10835    #[test]
10836    fn path_prefix_check_is_case_insensitive() {
10837        // Windows paths are case-insensitive — winget and pinget may write
10838        // them with different casing.
10839        assert!(path_starts_with_case_insensitive(
10840            Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
10841            Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
10842        ));
10843    }
10844
10845    #[cfg(windows)]
10846    #[test]
10847    fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
10848        let query = PackageQuery {
10849            source: Some("winget".to_owned()),
10850            ..PackageQuery::default()
10851        };
10852        assert_eq!(
10853            portable_source_identifier(&query),
10854            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10855        );
10856        // Case-insensitive — manifests/catalog may report differently.
10857        let query = PackageQuery {
10858            source: Some("WINGET".to_owned()),
10859            ..PackageQuery::default()
10860        };
10861        assert_eq!(
10862            portable_source_identifier(&query),
10863            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10864        );
10865    }
10866
10867    #[cfg(windows)]
10868    #[test]
10869    fn portable_source_identifier_passes_through_custom_sources() {
10870        let query = PackageQuery {
10871            source: Some("internal-feed".to_owned()),
10872            ..PackageQuery::default()
10873        };
10874        assert_eq!(portable_source_identifier(&query), "internal-feed");
10875    }
10876
10877    #[cfg(windows)]
10878    #[test]
10879    fn portable_source_identifier_defaults_for_missing_or_empty_source() {
10880        let query = PackageQuery {
10881            source: None,
10882            ..PackageQuery::default()
10883        };
10884        assert_eq!(
10885            portable_source_identifier(&query),
10886            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10887        );
10888        let query = PackageQuery {
10889            source: Some(String::new()),
10890            ..PackageQuery::default()
10891        };
10892        assert_eq!(
10893            portable_source_identifier(&query),
10894            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10895        );
10896    }
10897
10898    #[cfg(windows)]
10899    #[test]
10900    fn portable_subkey_name_matches_winget_format() {
10901        assert_eq!(
10902            portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
10903            "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
10904        );
10905    }
10906
10907    #[cfg(windows)]
10908    #[test]
10909    fn determine_portable_alias_prefers_nested_installer_alias() {
10910        let installer = Installer {
10911            nested_installer_files: vec![NestedInstallerFile {
10912                relative_file_path: "ripgrep/rg.exe".to_owned(),
10913                portable_command_alias: Some("rg".to_owned()),
10914            }],
10915            commands: vec!["other-name".to_owned()],
10916            ..Installer::default()
10917        };
10918        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
10919    }
10920
10921    #[cfg(windows)]
10922    #[test]
10923    fn determine_portable_alias_falls_back_to_commands() {
10924        let installer = Installer {
10925            // No nested files at all (standalone portable case).
10926            commands: vec!["nuget".to_owned()],
10927            ..Installer::default()
10928        };
10929        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
10930
10931        // Nested files exist but none declare PortableCommandAlias.
10932        let installer = Installer {
10933            nested_installer_files: vec![NestedInstallerFile {
10934                relative_file_path: "subdir/bin.exe".to_owned(),
10935                portable_command_alias: None,
10936            }],
10937            commands: vec!["bin".to_owned()],
10938            ..Installer::default()
10939        };
10940        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
10941    }
10942
10943    #[cfg(windows)]
10944    #[test]
10945    fn determine_portable_alias_returns_none_without_alias_or_commands() {
10946        let installer = Installer::default();
10947        assert!(determine_portable_alias(&installer).is_none());
10948    }
10949
10950    #[cfg(windows)]
10951    #[test]
10952    fn resolve_portable_install_location_prefers_request_override() {
10953        let result = resolve_portable_install_location_pure(
10954            Some(r"D:\Tools\rg"),
10955            Some(r"C:\Software\rg-test"),
10956            "Sub_Source",
10957            Path::new(r"C:\default"),
10958        );
10959        assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
10960    }
10961
10962    #[cfg(windows)]
10963    #[test]
10964    fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
10965        // The #4823 scenario: pinget upgrade with no explicit --location must
10966        // resolve to the install_location the prior install recorded, not the
10967        // default portable root.
10968        let result = resolve_portable_install_location_pure(
10969            None,
10970            Some(r"C:\Software\rg-test"),
10971            "Sub_Source",
10972            Path::new(r"C:\default"),
10973        );
10974        assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
10975    }
10976
10977    #[cfg(windows)]
10978    #[test]
10979    fn resolve_portable_install_location_falls_back_to_default_root() {
10980        let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
10981        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
10982    }
10983
10984    #[cfg(windows)]
10985    #[test]
10986    fn resolve_portable_install_location_treats_empty_strings_as_unset() {
10987        // Defensive: PackageQuery / registry reads can yield "" instead of
10988        // None for missing values.
10989        let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
10990        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
10991    }
10992
10993    #[cfg(windows)]
10994    #[test]
10995    fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
10996        let root = std::env::temp_dir().join(format!(
10997            "pinget-clean-test-{}",
10998            Utc::now().timestamp_nanos_opt().unwrap_or_default()
10999        ));
11000        fs::create_dir_all(&root).expect("create temp dir");
11001        fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
11002        fs::create_dir_all(root.join("nested")).expect("create nested dir");
11003        fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
11004
11005        clean_directory_contents(&root).expect("clean root contents");
11006
11007        assert!(root.is_dir(), "root dir itself must survive");
11008        assert_eq!(
11009            fs::read_dir(&root).expect("read root").count(),
11010            0,
11011            "root must be empty after clean"
11012        );
11013        let _ = fs::remove_dir_all(&root);
11014    }
11015
11016    #[cfg(windows)]
11017    #[test]
11018    fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
11019        let bogus = std::env::temp_dir().join(format!(
11020            "pinget-clean-test-bogus-{}",
11021            Utc::now().timestamp_nanos_opt().unwrap_or_default()
11022        ));
11023        assert!(!bogus.exists());
11024        clean_directory_contents(&bogus).expect("clean nonexistent dir");
11025        // Should not have created the dir.
11026        assert!(!bogus.exists());
11027    }
11028
11029    #[cfg(windows)]
11030    #[test]
11031    fn path_prefix_check_handles_mixed_separators() {
11032        // NestedInstallerFiles RelativeFilePath uses '/' in manifests, so the
11033        // path we joined can end up mixed-separator on Windows.
11034        assert!(path_starts_with_case_insensitive(
11035            Path::new(r"C:\Foo\sub/bin.exe"),
11036            Path::new(r"C:\Foo"),
11037        ));
11038    }
11039
11040    #[test]
11041    fn repository_options_capture_custom_host_settings() {
11042        let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
11043            .with_user_agent("pinget-rs-tests/1.0")
11044            .with_pre_indexed_source_auto_update_interval(None);
11045
11046        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
11047        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
11048        assert!(options.pre_indexed_source_auto_update_interval.is_none());
11049    }
11050
11051    #[test]
11052    fn dependency_install_requests_are_silent_and_not_forced() {
11053        let mut parent = InstallRequest::new(PackageQuery {
11054            source: Some("winget".to_owned()),
11055            ..PackageQuery::default()
11056        });
11057        parent.mode = InstallerMode::SilentWithProgress;
11058        parent.force = true;
11059        parent.accept_package_agreements = true;
11060        parent.ignore_security_hash = true;
11061        parent.dependency_source = Some("dependencies".to_owned());
11062
11063        let dependency =
11064            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
11065
11066        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
11067        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
11068        assert_eq!(dependency.mode, InstallerMode::Silent);
11069        assert!(!dependency.force);
11070        assert!(dependency.no_upgrade);
11071        assert!(dependency.accept_package_agreements);
11072        assert!(dependency.ignore_security_hash);
11073    }
11074
11075    #[test]
11076    fn storage_paths_use_configured_app_root() {
11077        let app_root = PathBuf::from(r"C:\temp\pinget-test");
11078        let source = SourceRecord {
11079            name: "winget/test".to_owned(),
11080            kind: SourceKind::PreIndexed,
11081            arg: "https://example.com/cache".to_owned(),
11082            identifier: "Test.Source".to_owned(),
11083            trust_level: "None".to_owned(),
11084            explicit: false,
11085            priority: 0,
11086            last_update: None,
11087            source_version: None,
11088            etag: None,
11089            last_modified: None,
11090        };
11091
11092        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
11093        assert_eq!(
11094            source_state_dir(&app_root, &source),
11095            app_root.join("sources").join("winget_test")
11096        );
11097        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
11098    }
11099
11100    #[cfg(windows)]
11101    #[test]
11102    fn packaged_layout_paths_resolve_from_packaged_app_root() {
11103        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
11104        let app_root = local_app_data
11105            .join("Packages")
11106            .join(PACKAGED_FAMILY_NAME)
11107            .join("LocalState");
11108        let source = SourceStore::default()
11109            .sources
11110            .into_iter()
11111            .find(|source| source.name == "winget")
11112            .expect("winget source");
11113        assert!(uses_packaged_layout(&app_root));
11114        assert!(
11115            user_settings_path(&app_root)
11116                .display()
11117                .to_string()
11118                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
11119        );
11120        assert!(
11121            packaged_file_cache_root(&app_root)
11122                .display()
11123                .to_string()
11124                .ends_with(&format!(
11125                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
11126                    PACKAGED_FAMILY_NAME
11127                ))
11128        );
11129        assert!(
11130            source_state_dir(&app_root, &source)
11131                .display()
11132                .to_string()
11133                .ends_with(&format!(
11134                    r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe",
11135                    PACKAGED_FAMILY_NAME
11136                ))
11137        );
11138        assert!(
11139            source_installed_db_path_with_keying(&app_root, &source, false)
11140                .display()
11141                .to_string()
11142                .ends_with(&format!(
11143                    r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db",
11144                    PACKAGED_FAMILY_NAME
11145                ))
11146        );
11147    }
11148
11149    #[cfg(windows)]
11150    #[test]
11151    fn default_app_root_outside_package_avoids_packaged_layout() {
11152        // Tests run without AppX package identity, so the default app root must not
11153        // resolve to the WinGet packaged LocalState — that location requires a
11154        // brokered/elevated writer for its secure-settings stream.
11155        let prior = std::env::var_os("PINGET_APPROOT");
11156        // SAFETY: tests in this module are not run concurrently with other env mutators.
11157        unsafe { std::env::remove_var("PINGET_APPROOT") };
11158
11159        let result = default_app_root();
11160
11161        if let Some(prior) = prior {
11162            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
11163            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
11164        }
11165
11166        let app_root = result.expect("default app root");
11167        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
11168        assert!(
11169            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
11170            "unexpected fallback app root: {app_root:?}"
11171        );
11172    }
11173
11174    #[test]
11175    fn packaged_source_stream_overlays_defaults_and_metadata() {
11176        let store = parse_packaged_source_store(
11177            Some(
11178                "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",
11179            ),
11180            Some(
11181                "Sources:\n  - Name: corp\n    LastUpdate: 1700000000\n    SourceVersion: 1.2.3\n    ETag: \"etag-v1\"\n    LastModified: \"Wed, 03 Jun 2026 12:00:00 GMT\"\n",
11182            ),
11183        )
11184        .expect("packaged store");
11185
11186        assert!(!store.sources.iter().any(|source| source.name == "winget"));
11187        let corp = store
11188            .sources
11189            .iter()
11190            .find(|source| source.name == "corp")
11191            .expect("corp source");
11192        assert_eq!(corp.kind, SourceKind::Rest);
11193        assert_eq!(corp.identifier, "Contoso.Rest");
11194        assert_eq!(corp.trust_level, "Trusted");
11195        assert!(corp.explicit);
11196        assert_eq!(corp.priority, 7);
11197        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
11198        assert_eq!(corp.etag.as_deref(), Some("etag-v1"));
11199        assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT"));
11200        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
11201    }
11202
11203    #[test]
11204    fn system_winget_export_parses_json_lines() {
11205        let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11206{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11207{"Arg":"https://cdn.winget.microsoft.com/cache","Data":"Microsoft.Winget.Source_8wekyb3d8bbwe","Explicit":false,"Identifier":"Microsoft.Winget.Source_8wekyb3d8bbwe","Name":"winget","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
11208{"Arg":"https://cdn.winget.microsoft.com/fonts","Data":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Name":"winget-font","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
11209"#;
11210
11211        let sources = parse_system_winget_source_export(output).expect("source export");
11212
11213        assert_eq!(
11214            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
11215            vec!["winget.pro", "msstore", "winget", "winget-font"]
11216        );
11217        assert_eq!(sources[0].kind, SourceKind::Rest);
11218        assert_eq!(sources[0].arg, "https://api.winget.pro/feed");
11219        assert_eq!(sources[0].identifier, "api.winget.pro");
11220        assert_eq!(sources[0].trust_level, "Trusted");
11221        assert!(!sources[0].explicit);
11222        assert_eq!(sources[2].kind, SourceKind::PreIndexed);
11223        assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe");
11224        assert!(!sources[2].explicit);
11225    }
11226
11227    #[test]
11228    fn system_winget_export_parses_wrapped_sources() {
11229        let output = r#"{
11230  "Sources": [
11231    {
11232      "Name": "corp",
11233      "Type": "Microsoft.Rest",
11234      "Arg": "https://packages.contoso.test/api",
11235      "Identifier": "Contoso.Rest",
11236      "TrustLevel": "Trusted",
11237      "Explicit": true,
11238      "Priority": 9
11239    }
11240  ]
11241}"#;
11242
11243        let sources = parse_system_winget_source_export(output).expect("source export");
11244        let source = sources.first().expect("source");
11245        assert_eq!(sources.len(), 1);
11246        assert_eq!(source.name, "corp");
11247        assert_eq!(source.kind, SourceKind::Rest);
11248        assert_eq!(source.identifier, "Contoso.Rest");
11249        assert_eq!(source.trust_level, "Trusted");
11250        assert!(source.explicit);
11251        assert_eq!(source.priority, 9);
11252    }
11253
11254    #[test]
11255    fn system_winget_source_store_detects_secure_settings_stub() {
11256        assert!(is_secure_settings_stub(Some(
11257            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
11258        )));
11259        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
11260    }
11261
11262    #[test]
11263    fn system_winget_source_store_builds_add_arguments() {
11264        let args = build_winget_source_add_arguments(
11265            "corp",
11266            "https://packages.contoso.test/api",
11267            SourceKind::Rest,
11268            "Trusted",
11269            true,
11270        );
11271
11272        assert_eq!(
11273            args,
11274            vec![
11275                "source",
11276                "add",
11277                "--name",
11278                "corp",
11279                "--arg",
11280                "https://packages.contoso.test/api",
11281                "--type",
11282                "Microsoft.Rest",
11283                "--disable-interactivity",
11284                "--trust-level",
11285                "trusted",
11286                "--explicit",
11287            ]
11288        );
11289    }
11290
11291    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
11292        Ok(WingetSourceCommandResult {
11293            exit_code: 0,
11294            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11295"#
11296            .to_owned(),
11297            stderr: String::new(),
11298        })
11299    }
11300
11301    #[test]
11302    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
11303        let original_runner = {
11304            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11305            let original = *runner;
11306            *runner = fake_system_winget_source_export;
11307            original
11308        };
11309
11310        let result = load_packaged_store_from_streams(
11311            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
11312            None,
11313        );
11314
11315        {
11316            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11317            *runner = original_runner;
11318        }
11319
11320        let store = result.expect("packaged store").expect("packaged store");
11321        let source = store.sources.first().expect("source");
11322        assert_eq!(store.sources.len(), 1);
11323        assert_eq!(source.name, "contoso");
11324        assert_eq!(source.kind, SourceKind::Rest);
11325    }
11326
11327    #[test]
11328    fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() {
11329        let original_runner = {
11330            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11331            let original = *runner;
11332            *runner = fake_system_winget_source_export;
11333            original
11334        };
11335
11336        let app_root = temp_app_root("system-winget-mirror");
11337        let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror);
11338
11339        {
11340            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11341            *runner = original_runner;
11342        }
11343
11344        let (mode, mut store) = result.expect("mirror store");
11345        assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror);
11346        assert!(system_winget_mirror_store_path(&app_root).exists());
11347        assert!(
11348            !store_path(&app_root).exists(),
11349            "mirror mode must not write private sources.json"
11350        );
11351
11352        let source = store.sources.first_mut().expect("source");
11353        source.last_update = Some(Utc::now());
11354        source.source_version = Some("cached-contract".to_owned());
11355        source.etag = Some("\"etag\"".to_owned());
11356        source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
11357        save_system_winget_mirror_store(&app_root, &store).expect("save mirror");
11358
11359        let original_runner = {
11360            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11361            let original = *runner;
11362            *runner = fake_system_winget_source_export;
11363            original
11364        };
11365        let refreshed = refresh_system_winget_mirror_store(&app_root);
11366        {
11367            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11368            *runner = original_runner;
11369        }
11370
11371        let refreshed = refreshed.expect("refresh mirror");
11372        let source = refreshed.sources.first().expect("source");
11373        assert_eq!(source.source_version.as_deref(), Some("cached-contract"));
11374        assert_eq!(source.etag.as_deref(), Some("\"etag\""));
11375        assert_eq!(
11376            source_state_dir_with_keying(&app_root, source, true),
11377            app_root.join("sources").join("api.contoso.test")
11378        );
11379        assert_eq!(
11380            source_state_dir_with_keying(&app_root, source, false),
11381            app_root.join("sources").join("contoso")
11382        );
11383
11384        let _ = fs::remove_dir_all(&app_root);
11385    }
11386
11387    #[test]
11388    fn source_metadata_and_named_reset_round_trip() {
11389        let app_root = temp_app_root("source-reset");
11390        let result = (|| -> Result<()> {
11391            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11392            repository.add_source_with_metadata(
11393                "test",
11394                "https://example.com/test",
11395                SourceKind::Rest,
11396                Some("trusted"),
11397                true,
11398                4,
11399            )?;
11400
11401            repository.edit_source("test", Some(false), None)?;
11402            let edited = repository
11403                .list_sources()
11404                .into_iter()
11405                .find(|source| source.name == "test")
11406                .expect("source");
11407            assert_eq!(edited.trust_level, "Trusted");
11408            assert!(!edited.explicit);
11409            assert_eq!(edited.priority, 4);
11410
11411            repository.reset_source("test")?;
11412            let reset = repository
11413                .list_sources()
11414                .into_iter()
11415                .find(|source| source.name == "test")
11416                .expect("source");
11417            assert_eq!(reset.trust_level, "Trusted");
11418            assert!(!reset.explicit);
11419            assert_eq!(reset.priority, 4);
11420            assert!(reset.last_update.is_none());
11421            assert!(reset.source_version.is_none());
11422            Ok(())
11423        })();
11424
11425        let _ = fs::remove_dir_all(&app_root);
11426        result.expect("source metadata");
11427    }
11428
11429    #[test]
11430    fn explicit_sources_are_skipped_by_default_search_resolution() {
11431        let app_root = temp_app_root("source-resolution");
11432        let result = (|| -> Result<()> {
11433            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11434            repository.add_source_with_metadata(
11435                "explicit-test",
11436                "https://example.com/explicit",
11437                SourceKind::Rest,
11438                Some("trusted"),
11439                true,
11440                10,
11441            )?;
11442
11443            let indexes = repository.resolve_source_indexes(None)?;
11444            let names: Vec<_> = indexes
11445                .into_iter()
11446                .map(|index| repository.list_sources()[index].name.clone())
11447                .collect();
11448            assert!(!names.iter().any(|name| name == "explicit-test"));
11449            Ok(())
11450        })();
11451
11452        let _ = fs::remove_dir_all(&app_root);
11453        result.expect("source resolution");
11454    }
11455
11456    #[test]
11457    fn source_resolution_accepts_source_identifier() {
11458        let app_root = temp_app_root("source-identifier-resolution");
11459        let result = (|| -> Result<()> {
11460            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11461            repository.add_source_with_metadata(
11462                "winget.pro",
11463                "https://api.winget.pro/feed",
11464                SourceKind::Rest,
11465                Some("trusted"),
11466                false,
11467                0,
11468            )?;
11469            repository.store.sources[0].identifier = "api.winget.pro".to_owned();
11470
11471            let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?;
11472            assert_eq!(indexes, vec![0]);
11473            Ok(())
11474        })();
11475
11476        let _ = fs::remove_dir_all(&app_root);
11477        result.expect("source identifier resolution");
11478    }
11479
11480    #[test]
11481    fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() {
11482        let query = PackageQuery {
11483            id: Some("tessl.tessl".to_owned()),
11484            source: Some("winget".to_owned()),
11485            exact: true,
11486            ..PackageQuery::default()
11487        };
11488        let clean_miss = SearchLocatedResult {
11489            matches: Vec::new(),
11490            warnings: Vec::new(),
11491            failures: Vec::new(),
11492            truncated: false,
11493        };
11494        assert!(should_retry_show_across_system_winget_sources(
11495            true,
11496            &query,
11497            &clean_miss
11498        ));
11499
11500        let private_mode = false;
11501        assert!(!should_retry_show_across_system_winget_sources(
11502            private_mode,
11503            &query,
11504            &clean_miss
11505        ));
11506
11507        let explicit_custom_source = PackageQuery {
11508            source: Some("winget.pro".to_owned()),
11509            ..query.clone()
11510        };
11511        assert!(!should_retry_show_across_system_winget_sources(
11512            true,
11513            &explicit_custom_source,
11514            &clean_miss
11515        ));
11516
11517        let failed_source = SearchLocatedResult {
11518            failures: vec![SourceSearchFailure {
11519                source_name: "winget".to_owned(),
11520                message: "network failure".to_owned(),
11521            }],
11522            ..clean_miss
11523        };
11524        assert!(!should_retry_show_across_system_winget_sources(
11525            true,
11526            &query,
11527            &failed_source
11528        ));
11529    }
11530
11531    #[test]
11532    fn user_and_admin_settings_round_trip() {
11533        let app_root = temp_app_root("settings");
11534        let result = (|| -> Result<()> {
11535            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11536            repository.set_user_settings(
11537                &serde_json::json!({
11538                    "visual": {
11539                        "progressBar": "retro"
11540                    }
11541                }),
11542                false,
11543            )?;
11544            repository.set_user_settings(
11545                &serde_json::json!({
11546                    "experimentalFeatures": {
11547                        "directMSI": true
11548                    }
11549                }),
11550                true,
11551            )?;
11552
11553            let user_settings = repository.get_user_settings()?;
11554            assert_eq!(
11555                user_settings["visual"]["progressBar"],
11556                serde_json::Value::String("retro".to_owned())
11557            );
11558            assert_eq!(
11559                user_settings["experimentalFeatures"]["directMSI"],
11560                serde_json::Value::Bool(true)
11561            );
11562            assert!(repository.test_user_settings(
11563                &serde_json::json!({
11564                    "experimentalFeatures": {
11565                        "directMSI": true
11566                    }
11567                }),
11568                true
11569            )?);
11570
11571            repository.set_admin_setting("LocalManifestFiles", true)?;
11572            repository.set_admin_setting("InstallerHashOverride", true)?;
11573            let admin_settings = repository.get_admin_settings()?;
11574            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
11575            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
11576
11577            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
11578            let reset_one = repository.get_admin_settings()?;
11579            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
11580
11581            repository.reset_admin_setting(None, true)?;
11582            let reset_all = repository.get_admin_settings()?;
11583            for name in Repository::supported_admin_settings() {
11584                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
11585            }
11586            Ok(())
11587        })();
11588
11589        let _ = fs::remove_dir_all(&app_root);
11590        result.expect("settings");
11591    }
11592
11593    #[test]
11594    fn show_result_structured_document_is_manifest_oriented() {
11595        let result = ShowResult {
11596            package: SearchMatch {
11597                source_name: "winget".to_owned(),
11598                source_kind: SourceKind::PreIndexed,
11599                id: "Test.Package".to_owned(),
11600                name: "Test Package".to_owned(),
11601                moniker: Some("testpkg".to_owned()),
11602                version: Some("1.2.3".to_owned()),
11603                channel: Some("stable".to_owned()),
11604                match_criteria: Some("Id".to_owned()),
11605            },
11606            manifest: Manifest {
11607                id: "Test.Package".to_owned(),
11608                name: "Test Package".to_owned(),
11609                version: "1.2.3".to_owned(),
11610                channel: "stable".to_owned(),
11611                publisher: Some("Contoso".to_owned()),
11612                description: Some("Structured output".to_owned()),
11613                moniker: Some("testpkg".to_owned()),
11614                package_url: None,
11615                publisher_url: None,
11616                publisher_support_url: None,
11617                license: None,
11618                license_url: None,
11619                privacy_url: None,
11620                author: None,
11621                copyright: None,
11622                copyright_url: None,
11623                release_notes: None,
11624                release_notes_url: None,
11625                tags: vec!["utility".to_owned()],
11626                agreements: Vec::new(),
11627                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
11628                documentation: vec![Documentation {
11629                    label: Some("Docs".to_owned()),
11630                    url: "https://example.test/docs".to_owned(),
11631                }],
11632                icons: vec![PackageIcon {
11633                    icon_url: Some("https://example.test/default-256.ico".to_owned()),
11634                    icon_file_type: Some("ico".to_owned()),
11635                    icon_resolution: Some("256x256".to_owned()),
11636                    icon_theme: Some("default".to_owned()),
11637                    icon_sha256: Some("DEF456".to_owned()),
11638                }],
11639                installers: vec![Installer {
11640                    architecture: Some("x64".to_owned()),
11641                    installer_type: Some("msix".to_owned()),
11642                    url: Some("https://example.test/Test.Package.msix".to_owned()),
11643                    sha256: Some("ABC123".to_owned()),
11644                    product_code: None,
11645                    locale: Some("en-US".to_owned()),
11646                    scope: Some("machine".to_owned()),
11647                    release_date: None,
11648                    package_family_name: None,
11649                    upgrade_code: None,
11650                    platforms: Vec::new(),
11651                    minimum_os_version: None,
11652                    switches: InstallerSwitches {
11653                        silent: Some("/quiet".to_owned()),
11654                        ..InstallerSwitches::default()
11655                    },
11656                    commands: vec!["testpkg".to_owned()],
11657                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11658                    require_explicit_upgrade: false,
11659                    nested_installer_type: None,
11660                    nested_installer_files: Vec::new(),
11661                }],
11662                require_explicit_upgrade: false,
11663            },
11664            selected_installer: Some(Installer {
11665                architecture: Some("x64".to_owned()),
11666                installer_type: Some("msix".to_owned()),
11667                url: Some("https://example.test/Test.Package.msix".to_owned()),
11668                sha256: Some("ABC123".to_owned()),
11669                product_code: None,
11670                locale: Some("en-US".to_owned()),
11671                scope: Some("machine".to_owned()),
11672                release_date: None,
11673                package_family_name: None,
11674                upgrade_code: None,
11675                platforms: Vec::new(),
11676                minimum_os_version: None,
11677                switches: InstallerSwitches {
11678                    silent: Some("/quiet".to_owned()),
11679                    ..InstallerSwitches::default()
11680                },
11681                commands: vec!["testpkg".to_owned()],
11682                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11683                require_explicit_upgrade: false,
11684                nested_installer_type: None,
11685                nested_installer_files: Vec::new(),
11686            }),
11687            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
11688            warnings: vec!["cache warmed".to_owned()],
11689            manifest_documents: JsonValue::Array(vec![
11690                serde_json::json!({
11691                    "PackageIdentifier": "Test.Package",
11692                    "PackageVersion": "1.2.3",
11693                    "DefaultLocale": "en-US",
11694                    "ManifestType": "version",
11695                    "ManifestVersion": "1.10.0"
11696                }),
11697                serde_json::json!({
11698                    "PackageIdentifier": "Test.Package",
11699                    "PackageVersion": "1.2.3",
11700                    "PackageLocale": "en-US",
11701                    "PackageName": "Test Package",
11702                    "Publisher": "Example",
11703                    "License": "MIT",
11704                    "ShortDescription": "Structured output",
11705                    "Icons": [
11706                        {
11707                            "IconUrl": "https://example.test/default-256.ico",
11708                            "IconFileType": "ico",
11709                            "IconResolution": "256x256",
11710                            "IconTheme": "default",
11711                            "IconSha256": "DEF456"
11712                        }
11713                    ],
11714                    "ManifestType": "defaultLocale",
11715                    "ManifestVersion": "1.10.0"
11716                }),
11717                serde_json::json!({
11718                    "PackageIdentifier": "Test.Package",
11719                    "PackageVersion": "1.2.3",
11720                    "ManifestType": "installer",
11721                    "ManifestVersion": "1.10.0",
11722                    "Installers": [
11723                        {
11724                            "Architecture": "x64",
11725                            "InstallerType": "msix",
11726                            "InstallerUrl": "https://example.test/Test.Package.msix",
11727                            "InstallerSha256": "ABC123",
11728                            "Commands": ["testpkg"],
11729                            "InstallerSwitches": { "Silent": "/quiet" },
11730                            "Dependencies": {
11731                                "PackageDependencies": [
11732                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
11733                                ]
11734                            }
11735                        }
11736                    ]
11737                }),
11738            ]),
11739        };
11740
11741        let document = result.structured_document();
11742
11743        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
11744        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
11745        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
11746        assert_eq!(
11747            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
11748            Some("Microsoft.VCRedist.2015+.x64")
11749        );
11750        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
11751        assert_eq!(
11752            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
11753            Some("/quiet")
11754        );
11755        assert_eq!(
11756            document["IconUrl"].as_str(),
11757            Some("https://example.test/default-256.ico")
11758        );
11759        assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
11760        assert_eq!(
11761            document["Icons"][0]["IconUrl"].as_str(),
11762            Some("https://example.test/default-256.ico")
11763        );
11764    }
11765
11766    #[test]
11767    fn manifest_parses_require_explicit_upgrade_at_top_level() {
11768        // Top-level RequireExplicitUpgrade flag should propagate to
11769        // Manifest.require_explicit_upgrade. winget catalogs typically
11770        // place the flag here for browser packages and similar
11771        // self-updating apps that opt out of bulk `upgrade`.
11772        let yaml = r#"
11773PackageIdentifier: Test.Package
11774PackageVersion: 1.2.3
11775DefaultLocale: en-US
11776ManifestType: singleton
11777ManifestVersion: 1.10.0
11778PackageLocale: en-US
11779PackageName: Test Package
11780Publisher: Example
11781License: MIT
11782ShortDescription: explicit-upgrade fixture
11783RequireExplicitUpgrade: true
11784Installers:
11785  - Architecture: x64
11786    InstallerType: exe
11787    InstallerUrl: https://example.test/Test.Package.exe
11788    InstallerSha256: ABC123
11789"#;
11790        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11791        assert!(manifest.require_explicit_upgrade);
11792    }
11793
11794    #[test]
11795    fn manifest_parses_require_explicit_upgrade_on_installer() {
11796        // Per-installer flag — only one of several installers declares it,
11797        // but the Manifest aggregate must still be `true` because the user
11798        // could pick that installer when upgrading.
11799        let yaml = r#"
11800PackageIdentifier: Test.Package
11801PackageVersion: 1.2.3
11802DefaultLocale: en-US
11803ManifestType: singleton
11804ManifestVersion: 1.10.0
11805PackageLocale: en-US
11806PackageName: Test Package
11807Publisher: Example
11808License: MIT
11809ShortDescription: explicit-upgrade fixture
11810Installers:
11811  - Architecture: x64
11812    InstallerType: exe
11813    InstallerUrl: https://example.test/Test.Package.x64.exe
11814    InstallerSha256: ABC123
11815  - Architecture: arm64
11816    InstallerType: exe
11817    InstallerUrl: https://example.test/Test.Package.arm64.exe
11818    InstallerSha256: DEF456
11819    RequireExplicitUpgrade: true
11820"#;
11821        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11822        assert!(manifest.require_explicit_upgrade);
11823        assert!(!manifest.installers[0].require_explicit_upgrade);
11824        assert!(manifest.installers[1].require_explicit_upgrade);
11825    }
11826
11827    #[test]
11828    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
11829        let yaml = r#"
11830PackageIdentifier: Test.Package
11831PackageVersion: 1.2.3
11832DefaultLocale: en-US
11833ManifestType: singleton
11834ManifestVersion: 1.10.0
11835PackageLocale: en-US
11836PackageName: Test Package
11837Publisher: Example
11838License: MIT
11839ShortDescription: baseline fixture
11840Installers:
11841  - Architecture: x64
11842    InstallerType: exe
11843    InstallerUrl: https://example.test/Test.Package.exe
11844    InstallerSha256: ABC123
11845"#;
11846        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11847        assert!(!manifest.require_explicit_upgrade);
11848    }
11849
11850    #[test]
11851    fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
11852        let yaml = r#"
11853PackageIdentifier: Test.Package
11854PackageVersion: 1.2.3
11855DefaultLocale: en-US
11856ManifestType: singleton
11857ManifestVersion: 1.10.0
11858PackageLocale: en-US
11859PackageName: Test Package
11860Publisher: Example
11861License: MIT
11862ShortDescription: icon fixture
11863Icons:
11864  - IconUrl: https://example.test/dark-512.png
11865    IconFileType: png
11866    IconResolution: 512x512
11867    IconTheme: dark
11868    IconSha256: DARK
11869  - IconUrl: https://example.test/default-64.png
11870    IconFileType: png
11871    IconResolution: 64x64
11872    IconTheme: default
11873  - IconUrl: https://example.test/default-256.ico
11874    IconFileType: ico
11875    IconResolution: 256x256
11876    IconTheme: default
11877  - IconFileType: png
11878    IconResolution: 1024x1024
11879Installers:
11880  - Architecture: x64
11881    InstallerType: exe
11882    InstallerUrl: https://example.test/Test.Package.exe
11883    InstallerSha256: ABC123
11884"#;
11885        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11886
11887        assert_eq!(manifest.icons.len(), 4);
11888        assert_eq!(
11889            manifest.icons[0].icon_url.as_deref(),
11890            Some("https://example.test/dark-512.png")
11891        );
11892        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11893        let selected = manifest.icon().expect("selected icon");
11894        assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
11895        assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
11896    }
11897
11898    #[test]
11899    fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
11900        let yaml = r#"
11901PackageIdentifier: Test.Package
11902PackageVersion: 1.2.3
11903DefaultLocale: en-US
11904ManifestType: singleton
11905ManifestVersion: 1.10.0
11906PackageLocale: en-US
11907PackageName: Test Package
11908Publisher: Example
11909License: MIT
11910ShortDescription: no-icon fixture
11911Installers:
11912  - Architecture: x64
11913    InstallerType: exe
11914    InstallerUrl: https://example.test/Test.Package.exe
11915    InstallerSha256: ABC123
11916"#;
11917        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11918
11919        assert!(manifest.icons.is_empty());
11920        assert!(manifest.icon().is_none());
11921        assert!(manifest.icon_url().is_none());
11922    }
11923
11924    #[test]
11925    fn rest_manifest_parses_icons_from_default_locale() {
11926        let response = serde_json::json!({
11927            "Data": {
11928                "PackageIdentifier": "Test.Package",
11929                "Versions": [
11930                    {
11931                        "PackageVersion": "1.2.3",
11932                        "DefaultLocale": {
11933                            "PackageName": "Test Package",
11934                            "Publisher": "Example",
11935                            "ShortDescription": "REST icon fixture",
11936                            "Icons": [
11937                                {
11938                                    "IconUrl": "https://example.test/default-128.png",
11939                                    "IconFileType": "png",
11940                                    "IconResolution": "128x128",
11941                                    "IconTheme": "default"
11942                                },
11943                                {
11944                                    "IconUrl": "https://example.test/default-256.ico",
11945                                    "IconFileType": "ico",
11946                                    "IconResolution": "256x256"
11947                                }
11948                            ]
11949                        },
11950                        "Installers": [
11951                            {
11952                                "Architecture": "x64",
11953                                "InstallerType": "exe",
11954                                "InstallerUrl": "https://example.test/Test.Package.exe",
11955                                "InstallerSha256": "ABC123"
11956                            }
11957                        ],
11958                        "ManifestVersion": "1.10.0"
11959                    }
11960                ],
11961                "ManifestVersion": "1.10.0"
11962            }
11963        });
11964        let bytes = serde_json::to_vec(&response).expect("json");
11965
11966        let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
11967
11968        assert_eq!(manifest.icons.len(), 2);
11969        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11970        assert_eq!(
11971            document["Icons"][0]["IconUrl"].as_str(),
11972            Some("https://example.test/default-128.png")
11973        );
11974    }
11975
11976    #[test]
11977    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
11978        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
11979        // (Edge, Steam, Discord). pinget must do the same — the filter is
11980        // the only gate that enforces it.
11981        let mut pkg = InstalledPackage {
11982            name: "Edge".to_owned(),
11983            local_id: r"ARP\Machine\X64\Edge".to_owned(),
11984            installed_version: "100.0".to_owned(),
11985            publisher: None,
11986            scope: Some("Machine".to_owned()),
11987            installer_category: Some("exe".to_owned()),
11988            install_location: None,
11989            package_family_names: Vec::new(),
11990            product_codes: Vec::new(),
11991            upgrade_codes: Vec::new(),
11992            correlated: Some(SearchMatch {
11993                source_name: "winget".to_owned(),
11994                source_kind: SourceKind::PreIndexed,
11995                id: "Microsoft.Edge".to_owned(),
11996                name: "Microsoft Edge".to_owned(),
11997                moniker: None,
11998                version: Some("110.0".to_owned()),
11999                channel: None,
12000                match_criteria: None,
12001            }),
12002            winget_package_identifier: None,
12003            winget_source_identifier: None,
12004            installed_version_canonical: false,
12005            correlated_requires_explicit_upgrade: true,
12006            correlated_lacks_compatible_installer: false,
12007        };
12008        let bulk_query = ListQuery {
12009            upgrade_only: true,
12010            ..ListQuery::default()
12011        };
12012        assert!(
12013            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
12014            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
12015        );
12016
12017        // When the user explicitly targets it by id, winget shows it —
12018        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
12019        let filtered_query = ListQuery {
12020            upgrade_only: true,
12021            id: Some("Microsoft.Edge".to_owned()),
12022            ..ListQuery::default()
12023        };
12024        assert!(
12025            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
12026            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
12027        );
12028
12029        // Without the flag, the row appears in bulk upgrade as usual.
12030        pkg.correlated_requires_explicit_upgrade = false;
12031        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12032    }
12033
12034    #[test]
12035    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
12036        let mut pkg = InstalledPackage {
12037            name: "Foo".to_owned(),
12038            local_id: r"ARP\Machine\X64\Foo".to_owned(),
12039            installed_version: "1.0".to_owned(),
12040            publisher: None,
12041            scope: Some("Machine".to_owned()),
12042            installer_category: Some("exe".to_owned()),
12043            install_location: None,
12044            package_family_names: Vec::new(),
12045            product_codes: Vec::new(),
12046            upgrade_codes: Vec::new(),
12047            correlated: Some(SearchMatch {
12048                source_name: "winget".to_owned(),
12049                source_kind: SourceKind::PreIndexed,
12050                id: "Test.Foo".to_owned(),
12051                name: "Foo".to_owned(),
12052                moniker: None,
12053                version: Some("2.0".to_owned()),
12054                channel: None,
12055                match_criteria: None,
12056            }),
12057            winget_package_identifier: None,
12058            winget_source_identifier: None,
12059            installed_version_canonical: false,
12060            correlated_requires_explicit_upgrade: false,
12061            correlated_lacks_compatible_installer: true,
12062        };
12063        let bulk_query = ListQuery {
12064            upgrade_only: true,
12065            ..ListQuery::default()
12066        };
12067        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12068
12069        // Explicit `--id` still surfaces it.
12070        let filtered_query = ListQuery {
12071            upgrade_only: true,
12072            id: Some("Test.Foo".to_owned()),
12073            ..ListQuery::default()
12074        };
12075        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
12076
12077        // Cleared flag → visible in bulk.
12078        pkg.correlated_lacks_compatible_installer = false;
12079        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12080    }
12081
12082    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
12083        let installers = arches
12084            .iter()
12085            .map(|a| Installer {
12086                architecture: Some((*a).to_owned()),
12087                installer_type: None,
12088                url: None,
12089                sha256: None,
12090                product_code: None,
12091                locale: None,
12092                scope: None,
12093                release_date: None,
12094                package_family_name: None,
12095                upgrade_code: None,
12096                platforms: Vec::new(),
12097                minimum_os_version: None,
12098                switches: InstallerSwitches::default(),
12099                commands: Vec::new(),
12100                package_dependencies: Vec::new(),
12101                require_explicit_upgrade: false,
12102                nested_installer_type: None,
12103                nested_installer_files: Vec::new(),
12104            })
12105            .collect();
12106        Manifest {
12107            id: "Test".to_owned(),
12108            name: "Test".to_owned(),
12109            version: "1.0".to_owned(),
12110            channel: String::new(),
12111            publisher: None,
12112            description: None,
12113            moniker: None,
12114            package_url: None,
12115            publisher_url: None,
12116            publisher_support_url: None,
12117            license: None,
12118            license_url: None,
12119            privacy_url: None,
12120            author: None,
12121            copyright: None,
12122            copyright_url: None,
12123            release_notes: None,
12124            release_notes_url: None,
12125            tags: Vec::new(),
12126            agreements: Vec::new(),
12127            package_dependencies: Vec::new(),
12128            documentation: Vec::new(),
12129            icons: Vec::new(),
12130            installers,
12131            require_explicit_upgrade: false,
12132        }
12133    }
12134
12135    #[test]
12136    fn manifest_compatible_installer_neutral_always_matches() {
12137        // `neutral` is in every host's allowed list.
12138        assert!(manifest_has_compatible_installer(
12139            &synthetic_manifest_with_installer_arches(&["neutral"])
12140        ));
12141    }
12142
12143    #[test]
12144    fn manifest_compatible_installer_empty_installers_pass_through() {
12145        // Manifest with no installers (e.g. a docs-only entry): nothing to
12146        // filter against, don't hide the row.
12147        assert!(manifest_has_compatible_installer(
12148            &synthetic_manifest_with_installer_arches(&[])
12149        ));
12150    }
12151
12152    #[test]
12153    fn manifest_compatible_installer_rejects_alien_arch_only() {
12154        // `ppc` is in no host's allowed list — manifest has nothing runnable.
12155        assert!(!manifest_has_compatible_installer(
12156            &synthetic_manifest_with_installer_arches(&["ppc"])
12157        ));
12158    }
12159
12160    #[test]
12161    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
12162        // Mixed list where at least `neutral` is in every host's preference.
12163        assert!(manifest_has_compatible_installer(
12164            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
12165        ));
12166    }
12167
12168    #[test]
12169    fn lookup_unique_normalized_identity_returns_unique_match() {
12170        // Single (norm_name, norm_publisher) intersection — happy path.
12171        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12172        execute_batch_sql(
12173            &connection,
12174            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12175             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12176             INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
12177             INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
12178        )
12179        .expect("seed schema");
12180
12181        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
12182            .expect("query")
12183            .expect("unique match");
12184        assert_eq!(rowid, 100);
12185    }
12186
12187    #[test]
12188    fn lookup_installed_db_identity_match_returns_product_code_match() {
12189        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12190        execute_batch_sql(
12191            &connection,
12192            "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\
12193             CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\
12194             CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\
12195             CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\
12196             CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\
12197             INSERT INTO ids VALUES (1, 'Git.Git');\n\
12198             INSERT INTO names VALUES (1, 'Git');\n\
12199             INSERT INTO manifest VALUES (10, 1, 1);\n\
12200             INSERT INTO productcodes VALUES (20, 'git_is1');\n\
12201             INSERT INTO productcodes_map VALUES (10, 20);",
12202        )
12203        .expect("seed schema");
12204        let package = InstalledPackage {
12205            name: "Git".to_owned(),
12206            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
12207            installed_version: "2.54.0".to_owned(),
12208            publisher: None,
12209            scope: None,
12210            installer_category: None,
12211            install_location: None,
12212            package_family_names: Vec::new(),
12213            product_codes: vec!["git_is1".to_owned()],
12214            upgrade_codes: Vec::new(),
12215            correlated: None,
12216            winget_package_identifier: None,
12217            winget_source_identifier: None,
12218            installed_version_canonical: false,
12219            correlated_requires_explicit_upgrade: false,
12220            correlated_lacks_compatible_installer: false,
12221        };
12222
12223        let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package)
12224            .expect("query")
12225            .expect("product code match");
12226
12227        assert_eq!(id, "Git.Git");
12228        assert_eq!(name, "Git");
12229        assert_eq!(matched_by, "ProductCode");
12230    }
12231
12232    #[test]
12233    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
12234        // Two distinct packages share the same (norm_name, norm_publisher)
12235        // — the Git case where both `Git.Git` and a hypothetical sibling
12236        // normalize identically. winget refuses to correlate when it can't
12237        // disambiguate; pinget must do the same.
12238        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12239        execute_batch_sql(
12240            &connection,
12241            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12242             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12243             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12244             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
12245        )
12246        .expect("seed schema");
12247
12248        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12249        assert!(rowid.is_none(), "ambiguous match must not correlate");
12250    }
12251
12252    #[test]
12253    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
12254        // norm_name has multiple matches but only one shares its
12255        // norm_publisher with the installed package. Winget's intersect
12256        // logic still picks the right one — verify.
12257        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12258        execute_batch_sql(
12259            &connection,
12260            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12261             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12262             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12263             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
12264        )
12265        .expect("seed schema");
12266
12267        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12268        assert_eq!(rowid, Some(100));
12269    }
12270
12271    #[test]
12272    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
12273        // Name matches but no publisher row for that package id —
12274        // intersection is empty so we don't correlate.
12275        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12276        execute_batch_sql(
12277            &connection,
12278            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12279             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12280             INSERT INTO norm_names2 VALUES ('foo', 100);\n\
12281             INSERT INTO norm_publishers2 VALUES ('bar', 200);",
12282        )
12283        .expect("seed schema");
12284
12285        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
12286        assert!(
12287            rowid.is_none(),
12288            "name matches package 100 but publisher matches 200 — no intersection"
12289        );
12290    }
12291
12292    #[test]
12293    fn parse_yaml_manifest_bundle_returns_singleton_document() {
12294        let yaml = r#"
12295PackageIdentifier: Test.Package
12296PackageVersion: 1.2.3
12297DefaultLocale: en-US
12298ManifestType: version
12299ManifestVersion: 1.10.0
12300---
12301PackageIdentifier: Test.Package
12302PackageVersion: 1.2.3
12303PackageLocale: en-US
12304PackageName: Test Package
12305Publisher: Example
12306License: MIT
12307ShortDescription: Structured output
12308ManifestType: defaultLocale
12309ManifestVersion: 1.10.0
12310---
12311PackageIdentifier: Test.Package
12312PackageVersion: 1.2.3
12313ManifestType: installer
12314ManifestVersion: 1.10.0
12315Installers:
12316  - Architecture: x64
12317    InstallerType: exe
12318    InstallerUrl: https://example.test/Test.Package.exe
12319    InstallerSha256: ABC123
12320"#;
12321
12322        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
12323
12324        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
12325        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
12326        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
12327    }
12328
12329    #[test]
12330    fn collapse_structured_documents_returns_plural_show_documents() {
12331        let documents = collapse_structured_documents(&[
12332            JsonValue::Array(vec![
12333                serde_json::json!({
12334                    "PackageIdentifier": "Test.Package.One",
12335                    "PackageVersion": "1.0.0",
12336                    "DefaultLocale": "en-US",
12337                    "ManifestType": "version",
12338                    "ManifestVersion": "1.10.0"
12339                }),
12340                serde_json::json!({
12341                    "PackageIdentifier": "Test.Package.One",
12342                    "PackageVersion": "1.0.0",
12343                    "PackageLocale": "en-US",
12344                    "PackageName": "Test Package One",
12345                    "ManifestType": "defaultLocale",
12346                    "ManifestVersion": "1.10.0"
12347                }),
12348                serde_json::json!({
12349                    "PackageIdentifier": "Test.Package.One",
12350                    "PackageVersion": "1.0.0",
12351                    "ManifestType": "installer",
12352                    "ManifestVersion": "1.10.0",
12353                    "Installers": [
12354                        {
12355                            "Architecture": "x64",
12356                            "InstallerType": "exe",
12357                            "InstallerUrl": "https://example.test/one.exe",
12358                            "InstallerSha256": "ABC123"
12359                        }
12360                    ]
12361                }),
12362            ]),
12363            serde_json::json!({
12364                "PackageIdentifier": "Test.Package.Two",
12365                "PackageVersion": "2.0.0",
12366                "PackageLocale": "en-US",
12367                "PackageName": "Test Package Two",
12368                "ManifestType": "singleton",
12369                "ManifestVersion": "1.12.0"
12370            }),
12371        ]);
12372
12373        assert_eq!(documents.len(), 2);
12374        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
12375        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
12376        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
12377        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
12378        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
12379    }
12380
12381    #[test]
12382    fn unsupported_action_result_marks_noop_and_warning() {
12383        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
12384
12385        assert!(result.success);
12386        assert!(result.no_op);
12387        assert_eq!(result.exit_code, 0);
12388        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
12389    }
12390
12391    #[test]
12392    fn create_install_no_op_result_honors_no_upgrade() {
12393        let mut request = InstallRequest::new(PackageQuery {
12394            id: Some("Contoso.App".to_owned()),
12395            ..PackageQuery::default()
12396        });
12397        request.no_upgrade = true;
12398
12399        let manifest = Manifest {
12400            id: "Contoso.App".to_owned(),
12401            name: "Contoso App".to_owned(),
12402            version: "2.0.0".to_owned(),
12403            channel: String::new(),
12404            publisher: None,
12405            description: None,
12406            moniker: None,
12407            package_url: None,
12408            publisher_url: None,
12409            publisher_support_url: None,
12410            license: None,
12411            license_url: None,
12412            privacy_url: None,
12413            author: None,
12414            copyright: None,
12415            copyright_url: None,
12416            release_notes: None,
12417            release_notes_url: None,
12418            tags: Vec::new(),
12419            agreements: Vec::new(),
12420            package_dependencies: Vec::new(),
12421            documentation: Vec::new(),
12422            icons: Vec::new(),
12423            installers: Vec::new(),
12424            require_explicit_upgrade: false,
12425        };
12426        let existing = ListMatch {
12427            name: "Contoso App".to_owned(),
12428            id: "Contoso.App".to_owned(),
12429            local_id: "Contoso.App".to_owned(),
12430            installed_version: "1.0.0".to_owned(),
12431            available_version: Some("2.0.0".to_owned()),
12432            source_name: Some("winget".to_owned()),
12433            publisher: None,
12434            scope: None,
12435            installer_category: None,
12436            install_location: None,
12437            package_family_names: Vec::new(),
12438            product_codes: Vec::new(),
12439            upgrade_codes: Vec::new(),
12440        };
12441
12442        let result =
12443            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12444
12445        assert!(result.success);
12446        assert!(result.no_op);
12447        assert_eq!(result.version, "1.0.0");
12448        assert_eq!(
12449            result.warnings,
12450            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
12451        );
12452    }
12453
12454    #[test]
12455    fn create_install_no_op_result_skips_reinstall_when_current() {
12456        let request = InstallRequest::new(PackageQuery {
12457            id: Some("Contoso.App".to_owned()),
12458            ..PackageQuery::default()
12459        });
12460
12461        let manifest = Manifest {
12462            id: "Contoso.App".to_owned(),
12463            name: "Contoso App".to_owned(),
12464            version: "2.0.0".to_owned(),
12465            channel: String::new(),
12466            publisher: None,
12467            description: None,
12468            moniker: None,
12469            package_url: None,
12470            publisher_url: None,
12471            publisher_support_url: None,
12472            license: None,
12473            license_url: None,
12474            privacy_url: None,
12475            author: None,
12476            copyright: None,
12477            copyright_url: None,
12478            release_notes: None,
12479            release_notes_url: None,
12480            tags: Vec::new(),
12481            agreements: Vec::new(),
12482            package_dependencies: Vec::new(),
12483            documentation: Vec::new(),
12484            icons: Vec::new(),
12485            installers: Vec::new(),
12486            require_explicit_upgrade: false,
12487        };
12488        let existing = ListMatch {
12489            name: "Contoso App".to_owned(),
12490            id: "Contoso.App".to_owned(),
12491            local_id: "Contoso.App".to_owned(),
12492            installed_version: "2.0.0".to_owned(),
12493            available_version: Some("2.0.0".to_owned()),
12494            source_name: Some("winget".to_owned()),
12495            publisher: None,
12496            scope: None,
12497            installer_category: None,
12498            install_location: None,
12499            package_family_names: Vec::new(),
12500            product_codes: Vec::new(),
12501            upgrade_codes: Vec::new(),
12502        };
12503
12504        let result =
12505            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12506
12507        assert!(result.success);
12508        assert!(result.no_op);
12509        assert_eq!(result.version, "2.0.0");
12510        assert_eq!(
12511            result.warnings,
12512            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
12513        );
12514    }
12515
12516    #[test]
12517    fn collapse_structured_document_projects_merged_manifest() {
12518        let document = collapse_structured_document(&serde_json::json!({
12519            "PackageIdentifier": "Test.Package",
12520            "PackageVersion": "1.2.3",
12521            "PackageLocale": "en-US",
12522            "PackageName": "Test Package",
12523            "Publisher": "Example",
12524            "InstallerType": "exe",
12525            "Installers": [
12526                {
12527                    "Architecture": "x64",
12528                    "InstallerUrl": "https://example.test/Test.Package.exe",
12529                    "InstallerSha256": "ABC123"
12530                }
12531            ],
12532            "ManifestType": "merged",
12533            "ManifestVersion": "1.10.0"
12534        }));
12535
12536        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
12537        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
12538        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
12539        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
12540        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
12541    }
12542
12543    #[test]
12544    fn parses_fixture_manifest() {
12545        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
12546            .join("tests")
12547            .join("fixtures")
12548            .join("ManifestV1_28-Singleton.yaml");
12549        let bytes = fs::read(root).expect("fixture bytes");
12550        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
12551        assert_eq!(manifest.id, "microsoft.msixsdk");
12552        assert_eq!(manifest.version, "1.7.32");
12553        assert_eq!(manifest.name, "MSIX SDK");
12554        assert!(!manifest.installers.is_empty());
12555    }
12556
12557    #[test]
12558    fn parses_installer_switches_from_yaml_manifest() {
12559        let yaml = r#"
12560PackageIdentifier: Test.Package
12561PackageVersion: 1.2.3
12562PackageName: Test Package
12563InstallerSwitches:
12564  SilentWithProgress: /SILENT
12565Installers:
12566  - Architecture: x64
12567    InstallerType: inno
12568    InstallerUrl: https://example.test/Test.Package.exe
12569    InstallerSha256: ABC123
12570    InstallerSwitches:
12571      Silent: /VERYSILENT
12572      Interactive: /HELP
12573"#;
12574
12575        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12576        let installer = &manifest.installers[0];
12577
12578        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
12579        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
12580        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
12581    }
12582
12583    #[test]
12584    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
12585        let yaml = r#"
12586PackageIdentifier: Test.Package
12587PackageVersion: 1.2.3
12588PackageName: Test Package
12589Platform:
12590  - Windows.Desktop
12591MinimumOSVersion: 10.0.19041.0
12592Installers:
12593  - Architecture: x64
12594    InstallerType: exe
12595    InstallerUrl: https://example.test/Test.Package.exe
12596    InstallerSha256: ABC123
12597  - Architecture: x64
12598    InstallerType: msix
12599    Platform:
12600      - Windows.Universal
12601    MinimumOSVersion: 10.0.22621.0
12602    InstallerUrl: https://example.test/Test.Package.msix
12603    InstallerSha256: DEF456
12604"#;
12605
12606        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12607
12608        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
12609        assert_eq!(
12610            manifest.installers[0].minimum_os_version.as_deref(),
12611            Some("10.0.19041.0")
12612        );
12613        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
12614        assert_eq!(
12615            manifest.installers[1].minimum_os_version.as_deref(),
12616            Some("10.0.22621.0")
12617        );
12618    }
12619
12620    #[test]
12621    fn installer_switch_arguments_prefer_manifest_switches() {
12622        let installer = Installer {
12623            architecture: None,
12624            installer_type: Some("inno".to_owned()),
12625            url: None,
12626            sha256: None,
12627            product_code: None,
12628            locale: None,
12629            scope: None,
12630            release_date: None,
12631            package_family_name: None,
12632            upgrade_code: None,
12633            platforms: Vec::new(),
12634            minimum_os_version: None,
12635            switches: InstallerSwitches {
12636                silent: Some("/mysilent".to_owned()),
12637                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
12638                interactive: Some("/myinteractive".to_owned()),
12639                ..InstallerSwitches::default()
12640            },
12641            commands: Vec::new(),
12642            package_dependencies: Vec::new(),
12643            require_explicit_upgrade: false,
12644            nested_installer_type: None,
12645            nested_installer_files: Vec::new(),
12646        };
12647        let manifest = Manifest {
12648            id: "Test.Package".to_owned(),
12649            name: "Test Package".to_owned(),
12650            version: "1.0.0".to_owned(),
12651            channel: String::new(),
12652            publisher: None,
12653            description: None,
12654            moniker: None,
12655            package_url: None,
12656            publisher_url: None,
12657            publisher_support_url: None,
12658            license: None,
12659            license_url: None,
12660            privacy_url: None,
12661            author: None,
12662            copyright: None,
12663            copyright_url: None,
12664            release_notes: None,
12665            release_notes_url: None,
12666            tags: Vec::new(),
12667            agreements: Vec::new(),
12668            package_dependencies: Vec::new(),
12669            documentation: Vec::new(),
12670            icons: Vec::new(),
12671            installers: Vec::new(),
12672            require_explicit_upgrade: false,
12673        };
12674
12675        let mut silent_request = InstallRequest::new(PackageQuery::default());
12676        silent_request.mode = InstallerMode::Silent;
12677        let mut progress_request = InstallRequest::new(PackageQuery::default());
12678        progress_request.mode = InstallerMode::SilentWithProgress;
12679        let mut interactive_request = InstallRequest::new(PackageQuery::default());
12680        interactive_request.mode = InstallerMode::Interactive;
12681        assert_eq!(
12682            installer_command_arguments(
12683                "inno",
12684                &silent_request,
12685                &manifest,
12686                Path::new("installer.exe"),
12687                &installer
12688            ),
12689            vec!["/mysilent".to_owned()]
12690        );
12691        assert_eq!(
12692            installer_command_arguments(
12693                "inno",
12694                &progress_request,
12695                &manifest,
12696                Path::new("installer.exe"),
12697                &installer
12698            ),
12699            vec!["/mysilentwithprogress".to_owned()]
12700        );
12701        assert_eq!(
12702            installer_command_arguments(
12703                "inno",
12704                &interactive_request,
12705                &manifest,
12706                Path::new("installer.exe"),
12707                &installer
12708            ),
12709            vec!["/myinteractive".to_owned()]
12710        );
12711    }
12712
12713    #[test]
12714    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
12715        let installer = Installer {
12716            architecture: None,
12717            installer_type: Some("inno".to_owned()),
12718            url: None,
12719            sha256: None,
12720            product_code: None,
12721            locale: None,
12722            scope: None,
12723            release_date: None,
12724            package_family_name: None,
12725            upgrade_code: None,
12726            platforms: Vec::new(),
12727            minimum_os_version: None,
12728            switches: InstallerSwitches::default(),
12729            commands: Vec::new(),
12730            package_dependencies: Vec::new(),
12731            require_explicit_upgrade: false,
12732            nested_installer_type: None,
12733            nested_installer_files: Vec::new(),
12734        };
12735        let manifest = Manifest {
12736            id: "Test.Package".to_owned(),
12737            name: "Test Package".to_owned(),
12738            version: "1.0.0".to_owned(),
12739            channel: String::new(),
12740            publisher: None,
12741            description: None,
12742            moniker: None,
12743            package_url: None,
12744            publisher_url: None,
12745            publisher_support_url: None,
12746            license: None,
12747            license_url: None,
12748            privacy_url: None,
12749            author: None,
12750            copyright: None,
12751            copyright_url: None,
12752            release_notes: None,
12753            release_notes_url: None,
12754            tags: Vec::new(),
12755            agreements: Vec::new(),
12756            package_dependencies: Vec::new(),
12757            documentation: Vec::new(),
12758            icons: Vec::new(),
12759            installers: Vec::new(),
12760            require_explicit_upgrade: false,
12761        };
12762        let mut progress_request = InstallRequest::new(PackageQuery::default());
12763        progress_request.mode = InstallerMode::SilentWithProgress;
12764        let mut silent_request = InstallRequest::new(PackageQuery::default());
12765        silent_request.mode = InstallerMode::Silent;
12766
12767        assert_eq!(
12768            installer_command_arguments(
12769                "inno",
12770                &progress_request,
12771                &manifest,
12772                Path::new("installer.exe"),
12773                &installer
12774            ),
12775            vec![
12776                "/SP-".to_owned(),
12777                "/SILENT".to_owned(),
12778                "/SUPPRESSMSGBOXES".to_owned(),
12779                "/NORESTART".to_owned()
12780            ]
12781        );
12782        assert_eq!(
12783            installer_command_arguments(
12784                "inno",
12785                &silent_request,
12786                &manifest,
12787                Path::new("installer.exe"),
12788                &installer
12789            ),
12790            vec![
12791                "/SP-".to_owned(),
12792                "/VERYSILENT".to_owned(),
12793                "/SUPPRESSMSGBOXES".to_owned(),
12794                "/NORESTART".to_owned()
12795            ]
12796        );
12797    }
12798
12799    #[test]
12800    fn installer_command_arguments_append_manifest_and_cli_switches() {
12801        let installer = Installer {
12802            architecture: None,
12803            installer_type: Some("msi".to_owned()),
12804            url: None,
12805            sha256: None,
12806            product_code: None,
12807            locale: None,
12808            scope: None,
12809            release_date: None,
12810            package_family_name: None,
12811            upgrade_code: None,
12812            platforms: Vec::new(),
12813            minimum_os_version: None,
12814            switches: InstallerSwitches {
12815                custom: Some("ADDLOCAL=Core".to_owned()),
12816                log: Some("/log \"<LOGPATH>\"".to_owned()),
12817                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
12818                ..InstallerSwitches::default()
12819            },
12820            commands: Vec::new(),
12821            package_dependencies: Vec::new(),
12822            require_explicit_upgrade: false,
12823            nested_installer_type: None,
12824            nested_installer_files: Vec::new(),
12825        };
12826        let manifest = Manifest {
12827            id: "ShareX.ShareX".to_owned(),
12828            name: "ShareX".to_owned(),
12829            version: "19.0.2".to_owned(),
12830            channel: String::new(),
12831            publisher: None,
12832            description: None,
12833            moniker: None,
12834            package_url: None,
12835            publisher_url: None,
12836            publisher_support_url: None,
12837            license: None,
12838            license_url: None,
12839            privacy_url: None,
12840            author: None,
12841            copyright: None,
12842            copyright_url: None,
12843            release_notes: None,
12844            release_notes_url: None,
12845            tags: Vec::new(),
12846            agreements: Vec::new(),
12847            package_dependencies: Vec::new(),
12848            documentation: Vec::new(),
12849            icons: Vec::new(),
12850            installers: Vec::new(),
12851            require_explicit_upgrade: false,
12852        };
12853        let mut request = InstallRequest::new(PackageQuery::default());
12854        request.mode = InstallerMode::Silent;
12855        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
12856        request.custom = Some("REBOOT=ReallySuppress".to_owned());
12857        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
12858
12859        assert_eq!(
12860            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
12861            vec![
12862                "/i".to_owned(),
12863                r"C:\temp\ShareX.msi".to_owned(),
12864                "/quiet".to_owned(),
12865                "/norestart".to_owned(),
12866                "/log".to_owned(),
12867                r"C:\temp\winget.log".to_owned(),
12868                "ADDLOCAL=Core".to_owned(),
12869                "REBOOT=ReallySuppress".to_owned(),
12870                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
12871            ]
12872        );
12873    }
12874
12875    #[test]
12876    fn msi_shell_execute_arguments_include_msiexec_target() {
12877        let installer = Installer {
12878            architecture: None,
12879            installer_type: Some("wix".to_owned()),
12880            url: None,
12881            sha256: None,
12882            product_code: None,
12883            locale: None,
12884            scope: None,
12885            release_date: None,
12886            package_family_name: None,
12887            upgrade_code: None,
12888            platforms: Vec::new(),
12889            minimum_os_version: None,
12890            switches: InstallerSwitches {
12891                log: Some("/log \"<LOGPATH>\"".to_owned()),
12892                install_location: Some("INSTALLDIR=\"<INSTALLPATH>\"".to_owned()),
12893                ..InstallerSwitches::default()
12894            },
12895            commands: Vec::new(),
12896            package_dependencies: Vec::new(),
12897            require_explicit_upgrade: false,
12898            nested_installer_type: None,
12899            nested_installer_files: Vec::new(),
12900        };
12901        let mut request = InstallRequest::new(PackageQuery::default());
12902        request.mode = InstallerMode::Silent;
12903        request.log_path = Some(PathBuf::from(r"C:\temp\node.log"));
12904        request.install_location = Some(r"C:\Program Files\nodejs".to_owned());
12905
12906        assert_eq!(
12907            installer_shell_execute_arguments(
12908                "wix",
12909                &request,
12910                &Manifest {
12911                    id: "OpenJS.NodeJS.22".to_owned(),
12912                    name: "Node.js".to_owned(),
12913                    version: "22.22.3".to_owned(),
12914                    channel: String::new(),
12915                    publisher: None,
12916                    description: None,
12917                    moniker: None,
12918                    package_url: None,
12919                    publisher_url: None,
12920                    publisher_support_url: None,
12921                    license: None,
12922                    license_url: None,
12923                    privacy_url: None,
12924                    author: None,
12925                    copyright: None,
12926                    copyright_url: None,
12927                    release_notes: None,
12928                    release_notes_url: None,
12929                    tags: Vec::new(),
12930                    agreements: Vec::new(),
12931                    package_dependencies: Vec::new(),
12932                    documentation: Vec::new(),
12933                    icons: Vec::new(),
12934                    installers: Vec::new(),
12935                    require_explicit_upgrade: false,
12936                },
12937                Path::new(r"C:\temp\node.msi"),
12938                &installer
12939            ),
12940            vec![
12941                "/i".to_owned(),
12942                r"C:\temp\node.msi".to_owned(),
12943                "/quiet".to_owned(),
12944                "/norestart".to_owned(),
12945                "/log".to_owned(),
12946                r"C:\temp\node.log".to_owned(),
12947                r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
12948            ]
12949        );
12950    }
12951
12952    #[test]
12953    fn msi_direct_arguments_omit_msiexec_target() {
12954        let installer = Installer::default();
12955        let mut request = InstallRequest::new(PackageQuery::default());
12956        request.mode = InstallerMode::Silent;
12957
12958        assert_eq!(
12959            installer_direct_msi_arguments(
12960                "wix",
12961                &request,
12962                &Manifest {
12963                    id: "Test.Package".to_owned(),
12964                    name: "Test".to_owned(),
12965                    version: "1.0.0".to_owned(),
12966                    channel: String::new(),
12967                    publisher: None,
12968                    description: None,
12969                    moniker: None,
12970                    package_url: None,
12971                    publisher_url: None,
12972                    publisher_support_url: None,
12973                    license: None,
12974                    license_url: None,
12975                    privacy_url: None,
12976                    author: None,
12977                    copyright: None,
12978                    copyright_url: None,
12979                    release_notes: None,
12980                    release_notes_url: None,
12981                    tags: Vec::new(),
12982                    agreements: Vec::new(),
12983                    package_dependencies: Vec::new(),
12984                    documentation: Vec::new(),
12985                    icons: Vec::new(),
12986                    installers: Vec::new(),
12987                    require_explicit_upgrade: false,
12988                },
12989                Path::new(r"C:\temp\test.msi"),
12990                &installer
12991            ),
12992            vec!["/quiet".to_owned(), "/norestart".to_owned()]
12993        );
12994    }
12995
12996    #[test]
12997    fn msi_direct_arguments_enable_uac_only_for_quiet_ui() {
12998        let args = vec![
12999            "/quiet".to_owned(),
13000            "/norestart".to_owned(),
13001            "/log".to_owned(),
13002            r"C:\temp\node.log".to_owned(),
13003            r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13004        ];
13005
13006        let parsed = parse_msi_direct_arguments(&args).expect("parsed");
13007
13008        assert_eq!(parsed.ui_level, 0x102);
13009        assert_eq!(
13010            parsed.properties,
13011            r#"REBOOT=ReallySuppress INSTALLDIR="C:\Program Files\nodejs""#
13012        );
13013        assert_eq!(parsed.log_path.as_deref(), Some(r"C:\temp\node.log"));
13014    }
13015
13016    #[cfg(windows)]
13017    #[test]
13018    fn machine_scope_silent_msi_uses_shell_execute_instead_of_direct_msi() {
13019        let mut request = InstallRequest::new(PackageQuery::default());
13020        request.mode = InstallerMode::Silent;
13021
13022        let installer = Installer {
13023            installer_type: Some("wix".to_owned()),
13024            scope: Some("machine".to_owned()),
13025            ..Installer::default()
13026        };
13027
13028        assert!(!should_run_msi_direct(&request, &installer));
13029        assert!(should_elevate_msi_shell_execute(&installer));
13030    }
13031
13032    #[test]
13033    fn installer_download_if_range_prefers_strong_etag_and_rejects_weak_etag() {
13034        let strong = InstallerDownloadMetadata {
13035            url: "https://example.test/app.exe".to_owned(),
13036            etag: Some(r#""abc""#.to_owned()),
13037            last_modified: Some("Wed, 03 Jun 2026 21:00:00 GMT".to_owned()),
13038            total_length: Some(100),
13039            offset: 50,
13040        };
13041        let weak = InstallerDownloadMetadata {
13042            etag: Some(r#"W/"abc""#.to_owned()),
13043            ..strong.clone()
13044        };
13045
13046        assert_eq!(installer_download_if_range(&strong).as_deref(), Some(r#""abc""#));
13047        assert_eq!(
13048            installer_download_if_range(&weak).as_deref(),
13049            Some("Wed, 03 Jun 2026 21:00:00 GMT")
13050        );
13051    }
13052
13053    #[test]
13054    fn parse_content_range_validates_resume_start_and_total() {
13055        let value = HeaderValue::from_static("bytes 50-99/100");
13056        let parsed = parse_content_range(Some(&value), 50).expect("content range");
13057
13058        assert_eq!(parsed.total_length, Some(100));
13059        assert!(parse_content_range(Some(&value), 49).is_err());
13060    }
13061
13062    #[test]
13063    fn installer_download_sidecar_paths_append_suffixes() {
13064        let path = Path::new(r"C:\temp\installer.msi");
13065
13066        assert_eq!(sidecar_path(path, "part"), PathBuf::from(r"C:\temp\installer.msi.part"));
13067        assert_eq!(
13068            sidecar_path(path, "part.json"),
13069            PathBuf::from(r"C:\temp\installer.msi.part.json")
13070        );
13071    }
13072
13073    #[test]
13074    fn shell_execute_argument_joining_quotes_spaces() {
13075        assert_eq!(
13076            join_windows_arguments(&[
13077                "/quiet".to_owned(),
13078                r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13079                r#"VALUE=has "quotes""#.to_owned(),
13080            ]),
13081            r#"/quiet "INSTALLDIR=C:\Program Files\nodejs" "VALUE=has \"quotes\"""#
13082        );
13083    }
13084
13085    #[test]
13086    fn decompresses_mszyml_payload() {
13087        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
13088        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13089        encoder.write_all(payload.as_bytes()).expect("write payload");
13090        let compressed = encoder.finish().expect("finish payload");
13091        let mut mszip = b"CK".to_vec();
13092        mszip.extend_from_slice(&compressed);
13093
13094        let decompressed = decompress_mszyml(&mszip).expect("decompress");
13095        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13096        assert_eq!(parsed.versions[0].version, "1.2.3");
13097    }
13098
13099    #[test]
13100    fn decompresses_mszyml_payload_with_prefix() {
13101        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
13102        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13103        encoder.write_all(payload.as_bytes()).expect("write payload");
13104        let compressed = encoder.finish().expect("finish payload");
13105        let mut prefixed = vec![0u8; 28];
13106        prefixed.extend_from_slice(b"CK");
13107        prefixed.extend_from_slice(&compressed);
13108
13109        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
13110        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13111        assert_eq!(parsed.versions[0].version, "1.2.3");
13112    }
13113
13114    #[test]
13115    fn compares_versions_naturally() {
13116        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
13117        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
13118        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
13119        assert_eq!(compare_version("25.4.1", "< 25.4.1"), Ordering::Greater);
13120        assert_eq!(compare_version("< 25.4.1", "25.4.1"), Ordering::Less);
13121    }
13122
13123    #[test]
13124    fn installer_hash_verification_fails_on_mismatch_without_override() {
13125        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
13126        assert!(result.is_err());
13127        assert!(
13128            result
13129                .expect_err("hash mismatch")
13130                .to_string()
13131                .contains("Installer hash mismatch")
13132        );
13133    }
13134
13135    #[test]
13136    fn installer_hash_verification_allows_mismatch_with_override() {
13137        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
13138        verify_installer_hash(None, b"pinget", false).expect("missing hash");
13139    }
13140
13141    #[test]
13142    fn search_query_many_includes_tag_and_command_conditions() {
13143        let query = PackageQuery {
13144            query: Some("terminal".to_owned()),
13145            ..PackageQuery::default()
13146        };
13147
13148        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13149
13150        assert!(where_clause.contains("tags2"));
13151        assert!(where_clause.contains("commands2"));
13152        assert_eq!(params.len(), 5);
13153    }
13154
13155    #[test]
13156    fn tag_and_command_conditions_are_non_correlated() {
13157        // The tag/command map lookups must be emitted as non-correlated
13158        // `rowid IN (SELECT ...)` subqueries. A correlated `EXISTS (... WHERE
13159        // map.owner = packages.rowid ...)` re-scans the un-indexed map tables
13160        // per package row, which is quadratic (~19s on the real winget index
13161        // under SQLite, and an effective hang under Turso). Guard against a
13162        // regression back to the correlated form.
13163        let query = PackageQuery {
13164            query: Some("terminal".to_owned()),
13165            ..PackageQuery::default()
13166        };
13167
13168        let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13169
13170        assert!(where_clause.contains("packages.rowid IN (SELECT"));
13171        assert!(!where_clause.contains("EXISTS"));
13172        assert!(!where_clause.contains("= packages.rowid"));
13173    }
13174
13175    #[test]
13176    fn show_query_single_omits_tag_and_command_conditions() {
13177        let query = PackageQuery {
13178            query: Some("terminal".to_owned()),
13179            ..PackageQuery::default()
13180        };
13181
13182        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
13183
13184        assert!(!where_clause.contains("tags2"));
13185        assert!(!where_clause.contains("commands2"));
13186        assert_eq!(params.len(), 3);
13187    }
13188
13189    #[test]
13190    fn explicit_tag_filter_uses_exact_match() {
13191        let query = PackageQuery {
13192            tag: Some("terminal".to_owned()),
13193            ..PackageQuery::default()
13194        };
13195
13196        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13197
13198        assert!(where_clause.contains("tags2"));
13199        assert_eq!(params, vec!["terminal".to_owned()]);
13200    }
13201
13202    #[test]
13203    fn rest_tag_filter_uses_exact_match() {
13204        let query = PackageQuery {
13205            tag: Some("terminal".to_owned()),
13206            ..PackageQuery::default()
13207        };
13208        let info = RestInformation {
13209            required_package_match_fields: Vec::new(),
13210            unsupported_package_match_fields: Vec::new(),
13211            required_query_parameters: Vec::new(),
13212            ..RestInformation::default()
13213        };
13214
13215        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
13216        let filters = body["Filters"].as_array().expect("filters");
13217
13218        assert_eq!(filters.len(), 1);
13219        assert_eq!(filters[0]["PackageMatchField"], "Tag");
13220        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
13221        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
13222    }
13223
13224    #[test]
13225    fn selects_installer_using_requested_filters() {
13226        let installers = vec![
13227            Installer {
13228                architecture: Some("x86".to_owned()),
13229                installer_type: Some("zip".to_owned()),
13230                url: None,
13231                sha256: None,
13232                product_code: None,
13233                locale: Some("en-US".to_owned()),
13234                scope: Some("user".to_owned()),
13235                release_date: None,
13236                package_family_name: None,
13237                upgrade_code: None,
13238                platforms: Vec::new(),
13239                minimum_os_version: None,
13240                switches: InstallerSwitches::default(),
13241                commands: Vec::new(),
13242                package_dependencies: Vec::new(),
13243                require_explicit_upgrade: false,
13244                nested_installer_type: None,
13245                nested_installer_files: Vec::new(),
13246            },
13247            Installer {
13248                architecture: Some("x64".to_owned()),
13249                installer_type: Some("msix".to_owned()),
13250                url: None,
13251                sha256: None,
13252                product_code: None,
13253                locale: Some("en-US".to_owned()),
13254                scope: Some("user".to_owned()),
13255                release_date: None,
13256                package_family_name: None,
13257                upgrade_code: None,
13258                platforms: Vec::new(),
13259                minimum_os_version: None,
13260                switches: InstallerSwitches::default(),
13261                commands: vec!["demo".to_owned()],
13262                package_dependencies: Vec::new(),
13263                require_explicit_upgrade: false,
13264                nested_installer_type: None,
13265                nested_installer_files: Vec::new(),
13266            },
13267        ];
13268        let query = PackageQuery {
13269            installer_type: Some("msix".to_owned()),
13270            installer_architecture: Some("x64".to_owned()),
13271            install_scope: Some("user".to_owned()),
13272            locale: Some("en-US".to_owned()),
13273            ..PackageQuery::default()
13274        };
13275
13276        let selected = select_installer(&installers, &query).expect("selected installer");
13277        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
13278        assert_eq!(selected.architecture.as_deref(), Some("x64"));
13279    }
13280
13281    #[test]
13282    fn selects_first_installer_when_rank_ties() {
13283        let installers = vec![
13284            Installer {
13285                architecture: Some("x64".to_owned()),
13286                installer_type: Some("exe".to_owned()),
13287                url: None,
13288                sha256: None,
13289                product_code: None,
13290                locale: Some("en-US".to_owned()),
13291                scope: Some("machine".to_owned()),
13292                release_date: None,
13293                package_family_name: None,
13294                upgrade_code: None,
13295                platforms: Vec::new(),
13296                minimum_os_version: None,
13297                switches: InstallerSwitches::default(),
13298                commands: Vec::new(),
13299                package_dependencies: Vec::new(),
13300                require_explicit_upgrade: false,
13301                nested_installer_type: None,
13302                nested_installer_files: Vec::new(),
13303            },
13304            Installer {
13305                architecture: Some("x64".to_owned()),
13306                installer_type: Some("exe".to_owned()),
13307                url: None,
13308                sha256: None,
13309                product_code: None,
13310                locale: Some("en-US".to_owned()),
13311                scope: Some("user".to_owned()),
13312                release_date: None,
13313                package_family_name: None,
13314                upgrade_code: None,
13315                platforms: Vec::new(),
13316                minimum_os_version: None,
13317                switches: InstallerSwitches::default(),
13318                commands: Vec::new(),
13319                package_dependencies: Vec::new(),
13320                require_explicit_upgrade: false,
13321                nested_installer_type: None,
13322                nested_installer_files: Vec::new(),
13323            },
13324        ];
13325
13326        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
13327        assert_eq!(selected.scope.as_deref(), Some("machine"));
13328    }
13329
13330    #[test]
13331    fn selects_installer_using_requested_platform() {
13332        let installers = vec![
13333            Installer {
13334                architecture: Some("x64".to_owned()),
13335                installer_type: Some("exe".to_owned()),
13336                url: None,
13337                sha256: None,
13338                product_code: None,
13339                locale: Some("en-US".to_owned()),
13340                scope: Some("user".to_owned()),
13341                release_date: None,
13342                package_family_name: None,
13343                upgrade_code: None,
13344                platforms: vec!["Windows.Universal".to_owned()],
13345                minimum_os_version: None,
13346                switches: InstallerSwitches::default(),
13347                commands: Vec::new(),
13348                package_dependencies: Vec::new(),
13349                require_explicit_upgrade: false,
13350                nested_installer_type: None,
13351                nested_installer_files: Vec::new(),
13352            },
13353            Installer {
13354                architecture: Some("x64".to_owned()),
13355                installer_type: Some("exe".to_owned()),
13356                url: None,
13357                sha256: None,
13358                product_code: None,
13359                locale: Some("en-US".to_owned()),
13360                scope: Some("user".to_owned()),
13361                release_date: None,
13362                package_family_name: None,
13363                upgrade_code: None,
13364                platforms: vec!["Windows.Desktop".to_owned()],
13365                minimum_os_version: None,
13366                switches: InstallerSwitches::default(),
13367                commands: Vec::new(),
13368                package_dependencies: Vec::new(),
13369                require_explicit_upgrade: false,
13370                nested_installer_type: None,
13371                nested_installer_files: Vec::new(),
13372            },
13373        ];
13374        let query = PackageQuery {
13375            installer_type: Some("exe".to_owned()),
13376            platform: Some("Windows.Desktop".to_owned()),
13377            ..PackageQuery::default()
13378        };
13379
13380        let selected = select_installer(&installers, &query).expect("selected installer");
13381        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
13382    }
13383
13384    #[test]
13385    fn selects_installer_using_requested_os_version() {
13386        let installers = vec![
13387            Installer {
13388                architecture: Some("x64".to_owned()),
13389                installer_type: Some("exe".to_owned()),
13390                url: None,
13391                sha256: None,
13392                product_code: None,
13393                locale: Some("en-US".to_owned()),
13394                scope: Some("user".to_owned()),
13395                release_date: None,
13396                package_family_name: None,
13397                upgrade_code: None,
13398                platforms: Vec::new(),
13399                minimum_os_version: Some("10.0.22621.0".to_owned()),
13400                switches: InstallerSwitches::default(),
13401                commands: Vec::new(),
13402                package_dependencies: Vec::new(),
13403                require_explicit_upgrade: false,
13404                nested_installer_type: None,
13405                nested_installer_files: Vec::new(),
13406            },
13407            Installer {
13408                architecture: Some("x64".to_owned()),
13409                installer_type: Some("exe".to_owned()),
13410                url: None,
13411                sha256: None,
13412                product_code: None,
13413                locale: Some("en-US".to_owned()),
13414                scope: Some("user".to_owned()),
13415                release_date: None,
13416                package_family_name: None,
13417                upgrade_code: None,
13418                platforms: Vec::new(),
13419                minimum_os_version: Some("10.0.19041.0".to_owned()),
13420                switches: InstallerSwitches::default(),
13421                commands: Vec::new(),
13422                package_dependencies: Vec::new(),
13423                require_explicit_upgrade: false,
13424                nested_installer_type: None,
13425                nested_installer_files: Vec::new(),
13426            },
13427        ];
13428        let query = PackageQuery {
13429            installer_type: Some("exe".to_owned()),
13430            os_version: Some("10.0.19045.0".to_owned()),
13431            ..PackageQuery::default()
13432        };
13433
13434        let selected = select_installer(&installers, &query).expect("selected installer");
13435        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
13436    }
13437
13438    #[test]
13439    fn derives_correlation_name_candidates() {
13440        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
13441        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
13442        assert!(candidates.contains(&"PowerToys".to_owned()));
13443    }
13444
13445    #[test]
13446    fn correlates_installed_package_to_available_match() {
13447        let installed = InstalledPackage {
13448            name: "PowerToys (Preview) x64".to_owned(),
13449            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13450            installed_version: "0.98.1".to_owned(),
13451            publisher: None,
13452            scope: Some("Machine".to_owned()),
13453            installer_category: Some("exe".to_owned()),
13454            install_location: None,
13455            package_family_names: Vec::new(),
13456            product_codes: Vec::new(),
13457            upgrade_codes: Vec::new(),
13458            correlated: None,
13459            winget_package_identifier: None,
13460            winget_source_identifier: None,
13461            installed_version_canonical: false,
13462            correlated_requires_explicit_upgrade: false,
13463            correlated_lacks_compatible_installer: false,
13464        };
13465        let candidates = vec![SearchMatch {
13466            source_name: "winget".to_owned(),
13467            source_kind: SourceKind::PreIndexed,
13468            id: "Microsoft.PowerToys".to_owned(),
13469            name: "PowerToys".to_owned(),
13470            moniker: None,
13471            version: Some("0.98.1".to_owned()),
13472            channel: None,
13473            match_criteria: None,
13474        }];
13475
13476        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13477        assert_eq!(correlated.id, "Microsoft.PowerToys");
13478    }
13479
13480    #[test]
13481    fn list_query_uses_available_lookup_for_tag_filters() {
13482        let query = ListQuery {
13483            tag: Some("terminal".to_owned()),
13484            ..ListQuery::default()
13485        };
13486
13487        assert!(list_query_needs_available_lookup(&query));
13488    }
13489
13490    #[test]
13491    fn list_tag_filter_requires_correlated_match() {
13492        let package = InstalledPackage {
13493            name: "PowerToys (Preview) x64".to_owned(),
13494            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13495            installed_version: "0.98.1".to_owned(),
13496            publisher: None,
13497            scope: Some("Machine".to_owned()),
13498            installer_category: Some("exe".to_owned()),
13499            install_location: None,
13500            package_family_names: Vec::new(),
13501            product_codes: Vec::new(),
13502            upgrade_codes: Vec::new(),
13503            correlated: None,
13504            winget_package_identifier: None,
13505            winget_source_identifier: None,
13506            installed_version_canonical: false,
13507            correlated_requires_explicit_upgrade: false,
13508            correlated_lacks_compatible_installer: false,
13509        };
13510        let query = ListQuery {
13511            tag: Some("powertoys".to_owned()),
13512            ..ListQuery::default()
13513        };
13514
13515        assert!(!list_package_matches(&package, &query));
13516    }
13517
13518    #[test]
13519    fn list_lookup_ignores_display_count() {
13520        let query = ListQuery {
13521            query: Some("git".to_owned()),
13522            count: Some(5),
13523            ..ListQuery::default()
13524        };
13525
13526        let package_query = package_query_from_list_query(&query);
13527        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
13528    }
13529
13530    #[test]
13531    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
13532        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
13533        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
13534        // whichever candidate happened to come last in iteration order. The installed
13535        // display name's prefix decides the correct one.
13536        let installed = InstalledPackage {
13537            name: "Notepad++ (ARM 64-bit)".to_owned(),
13538            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
13539            installed_version: "8.8.9".to_owned(),
13540            publisher: None,
13541            scope: Some("Machine".to_owned()),
13542            installer_category: Some("exe".to_owned()),
13543            install_location: None,
13544            package_family_names: Vec::new(),
13545            product_codes: Vec::new(),
13546            upgrade_codes: Vec::new(),
13547            correlated: None,
13548            winget_package_identifier: None,
13549            winget_source_identifier: None,
13550            installed_version_canonical: false,
13551            correlated_requires_explicit_upgrade: false,
13552            correlated_lacks_compatible_installer: false,
13553        };
13554        let candidates = vec![
13555            SearchMatch {
13556                source_name: "winget".to_owned(),
13557                source_kind: SourceKind::PreIndexed,
13558                id: "ndd.Notepad--".to_owned(),
13559                name: "Notepad--".to_owned(),
13560                moniker: None,
13561                version: Some("1.0.0".to_owned()),
13562                channel: None,
13563                match_criteria: None,
13564            },
13565            SearchMatch {
13566                source_name: "winget".to_owned(),
13567                source_kind: SourceKind::PreIndexed,
13568                id: "Notepad++.Notepad++".to_owned(),
13569                name: "Notepad++".to_owned(),
13570                moniker: None,
13571                version: Some("8.9.5".to_owned()),
13572                channel: None,
13573                match_criteria: None,
13574            },
13575        ];
13576
13577        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13578        assert_eq!(correlated.id, "Notepad++.Notepad++");
13579    }
13580
13581    #[test]
13582    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
13583        // Without the prefix bonus, the loose substring match picked up `Studio`
13584        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
13585        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
13586        // sort order favored the alphabetically-later candidate.
13587        let installed = InstalledPackage {
13588            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
13589            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
13590            installed_version: "40.10.18029".to_owned(),
13591            publisher: None,
13592            scope: Some("Machine".to_owned()),
13593            installer_category: Some("msi".to_owned()),
13594            install_location: None,
13595            package_family_names: Vec::new(),
13596            product_codes: Vec::new(),
13597            upgrade_codes: Vec::new(),
13598            correlated: None,
13599            winget_package_identifier: None,
13600            winget_source_identifier: None,
13601            installed_version_canonical: false,
13602            correlated_requires_explicit_upgrade: false,
13603            correlated_lacks_compatible_installer: false,
13604        };
13605        let candidates = vec![
13606            SearchMatch {
13607                source_name: "winget".to_owned(),
13608                source_kind: SourceKind::PreIndexed,
13609                id: "ZeroBrane.Studio".to_owned(),
13610                name: "Studio".to_owned(),
13611                moniker: None,
13612                version: Some("1.0".to_owned()),
13613                channel: None,
13614                match_criteria: None,
13615            },
13616            SearchMatch {
13617                source_name: "winget".to_owned(),
13618                source_kind: SourceKind::PreIndexed,
13619                id: "BrickLink.Studio".to_owned(),
13620                name: "Studio".to_owned(),
13621                moniker: None,
13622                version: Some("1.0".to_owned()),
13623                channel: None,
13624                match_criteria: None,
13625            },
13626            SearchMatch {
13627                source_name: "winget".to_owned(),
13628                source_kind: SourceKind::PreIndexed,
13629                id: "Microsoft.DotNet.SDK.10".to_owned(),
13630                name: "Microsoft .NET SDK 10.0".to_owned(),
13631                moniker: None,
13632                version: Some("10.0.204".to_owned()),
13633                channel: None,
13634                match_criteria: None,
13635            },
13636        ];
13637
13638        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13639        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
13640    }
13641
13642    #[test]
13643    fn msix_packages_do_not_correlate_via_name() {
13644        // MSIX correlation must go through the v2 index's `pfns2` table — name
13645        // fallback is wrong because two MSIX packages can legitimately share a
13646        // display name without sharing identity (`Microsoft Edge Stable` MSIX
13647        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
13648        // the catalog Inno installer). Even when the names match exactly the
13649        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
13650        // PFN hit; that lookup happens in `correlate_installed_via_index`.
13651        let installed = InstalledPackage {
13652            name: "Microsoft Teams".to_owned(),
13653            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13654            installed_version: "25290.205.4069.4894".to_owned(),
13655            publisher: None,
13656            scope: Some("User".to_owned()),
13657            installer_category: Some("msix".to_owned()),
13658            install_location: None,
13659            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13660            product_codes: Vec::new(),
13661            upgrade_codes: Vec::new(),
13662            correlated: None,
13663            winget_package_identifier: None,
13664            winget_source_identifier: None,
13665            installed_version_canonical: false,
13666            correlated_requires_explicit_upgrade: false,
13667            correlated_lacks_compatible_installer: false,
13668        };
13669        let candidates = vec![SearchMatch {
13670            source_name: "winget".to_owned(),
13671            source_kind: SourceKind::PreIndexed,
13672            id: "Microsoft.Teams".to_owned(),
13673            name: "Microsoft Teams".to_owned(),
13674            moniker: None,
13675            version: Some("26106.1906.4665.7308".to_owned()),
13676            channel: None,
13677            match_criteria: None,
13678        }];
13679
13680        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13681    }
13682
13683    #[test]
13684    fn name_fallback_refuses_ambiguous_winners() {
13685        // The user has Git installed (publisher "The Git Development Community"
13686        // — but publisher isn't in the scoring function). Two catalog packages
13687        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
13688        // publisher disambiguation they score identically; winget refuses to
13689        // correlate (the install lists with empty Source). pinget must do the
13690        // same to avoid manufacturing an upgrade against the wrong catalog
13691        // package.
13692        let installed = InstalledPackage {
13693            name: "Git".to_owned(),
13694            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
13695            installed_version: "2.53.0".to_owned(),
13696            publisher: Some("The Git Development Community".to_owned()),
13697            scope: Some("Machine".to_owned()),
13698            installer_category: Some("exe".to_owned()),
13699            install_location: None,
13700            package_family_names: Vec::new(),
13701            product_codes: Vec::new(),
13702            upgrade_codes: Vec::new(),
13703            correlated: None,
13704            winget_package_identifier: None,
13705            winget_source_identifier: None,
13706            installed_version_canonical: false,
13707            correlated_requires_explicit_upgrade: false,
13708            correlated_lacks_compatible_installer: false,
13709        };
13710        let candidates = vec![
13711            SearchMatch {
13712                source_name: "winget".to_owned(),
13713                source_kind: SourceKind::PreIndexed,
13714                id: "Git.Git".to_owned(),
13715                name: "Git".to_owned(),
13716                moniker: None,
13717                version: Some("2.54.0".to_owned()),
13718                channel: None,
13719                match_criteria: None,
13720            },
13721            SearchMatch {
13722                source_name: "winget".to_owned(),
13723                source_kind: SourceKind::PreIndexed,
13724                id: "Microsoft.Git".to_owned(),
13725                name: "Git".to_owned(),
13726                moniker: None,
13727                version: Some("2.53.0.0.7".to_owned()),
13728                channel: None,
13729                match_criteria: None,
13730            },
13731        ];
13732
13733        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13734    }
13735
13736    #[test]
13737    fn msix_with_resource_string_name_does_not_correlate() {
13738        // Some MSIX entries have unresolved resource-string display names
13739        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
13740        // unrelated catalog package via the loose substring rule.
13741        let installed = InstalledPackage {
13742            name: "ms-resource:appDisplayName".to_owned(),
13743            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
13744            installed_version: "1.28.239.0".to_owned(),
13745            publisher: None,
13746            scope: Some("User".to_owned()),
13747            installer_category: Some("msix".to_owned()),
13748            install_location: None,
13749            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
13750            product_codes: Vec::new(),
13751            upgrade_codes: Vec::new(),
13752            correlated: None,
13753            winget_package_identifier: None,
13754            winget_source_identifier: None,
13755            installed_version_canonical: false,
13756            correlated_requires_explicit_upgrade: false,
13757            correlated_lacks_compatible_installer: false,
13758        };
13759        let candidates = vec![SearchMatch {
13760            source_name: "winget".to_owned(),
13761            source_kind: SourceKind::PreIndexed,
13762            id: "Microsoft.AppInstaller".to_owned(),
13763            name: "App Installer".to_owned(),
13764            moniker: None,
13765            version: Some("1.28.240.0".to_owned()),
13766            channel: None,
13767            match_criteria: None,
13768        }];
13769
13770        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13771    }
13772
13773    #[test]
13774    fn strict_list_correlation_avoids_short_substring_matches() {
13775        let installed = InstalledPackage {
13776            name: "GitHub".to_owned(),
13777            local_id: r"ARP\User\X64\GitHub".to_owned(),
13778            installed_version: "1.0.0".to_owned(),
13779            publisher: None,
13780            scope: Some("User".to_owned()),
13781            installer_category: Some("exe".to_owned()),
13782            install_location: None,
13783            package_family_names: Vec::new(),
13784            product_codes: Vec::new(),
13785            upgrade_codes: Vec::new(),
13786            correlated: None,
13787            winget_package_identifier: None,
13788            winget_source_identifier: None,
13789            installed_version_canonical: false,
13790            correlated_requires_explicit_upgrade: false,
13791            correlated_lacks_compatible_installer: false,
13792        };
13793        let candidates = vec![SearchMatch {
13794            source_name: "winget".to_owned(),
13795            source_kind: SourceKind::PreIndexed,
13796            id: "Git.Git.PreRelease".to_owned(),
13797            name: "Git".to_owned(),
13798            moniker: None,
13799            version: Some("2.54.0".to_owned()),
13800            channel: None,
13801            match_criteria: None,
13802        }];
13803
13804        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
13805    }
13806
13807    #[test]
13808    fn detects_available_upgrade_from_correlated_package() {
13809        let package = InstalledPackage {
13810            name: "AzCopy v10".to_owned(),
13811            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
13812            installed_version: "10.32.2".to_owned(),
13813            publisher: None,
13814            scope: Some("Machine".to_owned()),
13815            installer_category: Some("exe".to_owned()),
13816            install_location: None,
13817            package_family_names: Vec::new(),
13818            product_codes: Vec::new(),
13819            upgrade_codes: Vec::new(),
13820            correlated: Some(SearchMatch {
13821                source_name: "winget".to_owned(),
13822                source_kind: SourceKind::PreIndexed,
13823                id: "Microsoft.Azure.AZCopy.10".to_owned(),
13824                name: "AzCopy v10".to_owned(),
13825                moniker: None,
13826                version: Some("10.32.3".to_owned()),
13827                channel: None,
13828                match_criteria: None,
13829            }),
13830            winget_package_identifier: None,
13831            winget_source_identifier: None,
13832            installed_version_canonical: false,
13833            correlated_requires_explicit_upgrade: false,
13834            correlated_lacks_compatible_installer: false,
13835        };
13836
13837        assert!(installed_package_has_upgrade(&package));
13838    }
13839
13840    #[test]
13841    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
13842        let package = InstalledPackage {
13843            name: "Example Tool".to_owned(),
13844            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
13845            installed_version: "Unknown".to_owned(),
13846            publisher: None,
13847            scope: Some("User".to_owned()),
13848            installer_category: Some("exe".to_owned()),
13849            install_location: None,
13850            package_family_names: Vec::new(),
13851            product_codes: Vec::new(),
13852            upgrade_codes: Vec::new(),
13853            correlated: Some(SearchMatch {
13854                source_name: "winget".to_owned(),
13855                source_kind: SourceKind::PreIndexed,
13856                id: "Contoso.ExampleTool".to_owned(),
13857                name: "Example Tool".to_owned(),
13858                moniker: None,
13859                version: Some("2.0.0".to_owned()),
13860                channel: None,
13861                match_criteria: None,
13862            }),
13863            winget_package_identifier: None,
13864            winget_source_identifier: None,
13865            installed_version_canonical: false,
13866            correlated_requires_explicit_upgrade: false,
13867            correlated_lacks_compatible_installer: false,
13868        };
13869        let query = ListQuery {
13870            upgrade_only: true,
13871            include_unknown: true,
13872            ..Default::default()
13873        };
13874
13875        assert!(installed_package_matches_upgrade_filter(&package, &query));
13876        assert_eq!(
13877            list_match_from_installed(package, &[]).available_version.as_deref(),
13878            Some("2.0.0")
13879        );
13880    }
13881
13882    #[test]
13883    fn create_repair_list_query_includes_installed_selectors() {
13884        let request = RepairRequest {
13885            query: PackageQuery {
13886                query: Some("powertoys".to_owned()),
13887                id: Some("Microsoft.PowerToys".to_owned()),
13888                name: Some("PowerToys".to_owned()),
13889                moniker: Some("powertoys".to_owned()),
13890                source: Some("winget".to_owned()),
13891                exact: true,
13892                version: Some("0.98.1".to_owned()),
13893                install_scope: Some("Machine".to_owned()),
13894                ..PackageQuery::default()
13895            },
13896            manifest_path: None,
13897            product_code: Some("{GUID}".to_owned()),
13898            mode: InstallerMode::Silent,
13899            log_path: Some(PathBuf::from("repair.log")),
13900            accept_package_agreements: true,
13901            force: true,
13902            ignore_security_hash: true,
13903        };
13904
13905        let query = Repository::create_repair_list_query(&request);
13906        assert_eq!(query.query.as_deref(), Some("powertoys"));
13907        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
13908        assert_eq!(query.name.as_deref(), Some("PowerToys"));
13909        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
13910        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
13911        assert_eq!(query.version.as_deref(), Some("0.98.1"));
13912        assert_eq!(query.source.as_deref(), Some("winget"));
13913        assert!(query.exact);
13914        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
13915        assert_eq!(query.count, Some(100));
13916    }
13917
13918    #[test]
13919    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
13920        let request = RepairRequest {
13921            query: PackageQuery {
13922                name: Some("PowerToys".to_owned()),
13923                source: Some("winget".to_owned()),
13924                locale: Some("en-US".to_owned()),
13925                installer_architecture: Some("x64".to_owned()),
13926                install_scope: Some("Machine".to_owned()),
13927                ..PackageQuery::default()
13928            },
13929            manifest_path: None,
13930            product_code: None,
13931            mode: InstallerMode::SilentWithProgress,
13932            log_path: Some(PathBuf::from("repair.log")),
13933            accept_package_agreements: true,
13934            force: false,
13935            ignore_security_hash: true,
13936        };
13937        let installed = ListMatch {
13938            name: "PowerToys".to_owned(),
13939            id: "Microsoft.PowerToys".to_owned(),
13940            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
13941            installed_version: "0.98.1".to_owned(),
13942            available_version: Some("0.98.2".to_owned()),
13943            source_name: Some("winget".to_owned()),
13944            publisher: None,
13945            scope: Some("Machine".to_owned()),
13946            installer_category: Some("exe".to_owned()),
13947            install_location: None,
13948            package_family_names: Vec::new(),
13949            product_codes: Vec::new(),
13950            upgrade_codes: Vec::new(),
13951        };
13952
13953        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
13954        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
13955        assert!(install_request.query.query.is_none());
13956        assert!(install_request.query.name.is_none());
13957        assert!(install_request.query.moniker.is_none());
13958        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
13959        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
13960        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
13961        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
13962        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
13963        assert!(install_request.query.exact);
13964        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
13965        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
13966        assert!(install_request.accept_package_agreements);
13967        assert!(install_request.force);
13968        assert!(install_request.ignore_security_hash);
13969    }
13970
13971    #[test]
13972    fn source_scoped_pin_operations_round_trip() {
13973        let app_root = temp_app_root("pins");
13974        let result = (|| -> Result<()> {
13975            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
13976            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
13977            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
13978            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
13979
13980            assert_eq!(repository.list_pins(None)?.len(), 3);
13981            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
13982            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
13983
13984            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
13985            assert_eq!(repository.list_pins(None)?.len(), 2);
13986            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
13987
13988            repository.reset_pins(Some("test"))?;
13989            let remaining = repository.list_pins(None)?;
13990            assert_eq!(remaining.len(), 1);
13991            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
13992
13993            repository.reset_pins(None)?;
13994            assert!(repository.list_pins(None)?.is_empty());
13995            Ok(())
13996        })();
13997
13998        let _ = fs::remove_dir_all(&app_root);
13999        result.expect("pin round trip");
14000    }
14001
14002    #[test]
14003    fn pin_operations_work_with_packaged_pin_schema() {
14004        let app_root = temp_app_root("pins_packaged_schema");
14005        let result = (|| -> Result<()> {
14006            let db_path = pins_db_path(&app_root);
14007            if let Some(parent) = db_path.parent() {
14008                fs::create_dir_all(parent)?;
14009            }
14010
14011            let conn = Repository::open_sqlite_connection(db_path.clone())?;
14012            execute_batch_sql(
14013                &conn,
14014                "CREATE TABLE pin (
14015                    package_id TEXT NOT NULL,
14016                    source_id TEXT NOT NULL,
14017                    type INTEGER NOT NULL,
14018                    version TEXT NOT NULL,
14019                    PRIMARY KEY (package_id, source_id)
14020                )",
14021            )?;
14022            conn.cacheflush().context("failed to flush pin database")?;
14023            drop(conn);
14024
14025            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14026            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
14027
14028            let pins = repository.list_pins(None)?;
14029            assert_eq!(pins.len(), 1);
14030            assert_eq!(pins[0].package_id, "Contoso.Tool");
14031            assert_eq!(pins[0].version, "1.2.*");
14032            assert_eq!(pins[0].source_id, "winget");
14033            assert_eq!(pins[0].pin_type, PinType::Gating);
14034
14035            let conn = Repository::open_sqlite_connection(db_path)?;
14036            let stored_pin = query_optional_value(
14037                &conn,
14038                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
14039                vec![
14040                    SqlValue::Text("Contoso.Tool".to_owned()),
14041                    SqlValue::Text("winget".to_owned()),
14042                ],
14043                |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
14044            )?
14045            .ok_or_else(|| anyhow!("expected stored pin"))?;
14046            assert_eq!(stored_pin.0, 3);
14047            assert_eq!(stored_pin.1, "1.2.*");
14048
14049            Ok(())
14050        })();
14051
14052        let _ = fs::remove_dir_all(&app_root);
14053        result.expect("packaged pin schema round trip");
14054    }
14055
14056    #[test]
14057    fn version_pin_patterns_match_exact_and_prefix_values() {
14058        assert!(version_matches_pin_pattern("1.2.3", "*"));
14059        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
14060        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
14061        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
14062        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
14063    }
14064
14065    #[test]
14066    fn source_specific_pins_override_source_agnostic_pins() {
14067        let item = ListMatch {
14068            name: "Contoso Tool".to_owned(),
14069            id: "Contoso.Tool".to_owned(),
14070            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14071            installed_version: "1.0.0".to_owned(),
14072            available_version: Some("2.0.0".to_owned()),
14073            source_name: Some("winget".to_owned()),
14074            publisher: None,
14075            scope: None,
14076            installer_category: None,
14077            install_location: None,
14078            package_family_names: Vec::new(),
14079            product_codes: Vec::new(),
14080            upgrade_codes: Vec::new(),
14081        };
14082        let pins = vec![
14083            PinRecord {
14084                package_id: "Contoso.Tool".to_owned(),
14085                version: "*".to_owned(),
14086                source_id: String::new(),
14087                pin_type: PinType::Blocking,
14088            },
14089            PinRecord {
14090                package_id: "Contoso.Tool".to_owned(),
14091                version: "2.0.*".to_owned(),
14092                source_id: "winget".to_owned(),
14093                pin_type: PinType::Pinning,
14094            },
14095        ];
14096
14097        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
14098    }
14099
14100    #[test]
14101    fn blocking_pin_blocks_upgrade() {
14102        let item = ListMatch {
14103            name: "Contoso Tool".to_owned(),
14104            id: "Contoso.Tool".to_owned(),
14105            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14106            installed_version: "1.0.0".to_owned(),
14107            available_version: Some("2.0.0".to_owned()),
14108            source_name: Some("winget".to_owned()),
14109            publisher: None,
14110            scope: None,
14111            installer_category: None,
14112            install_location: None,
14113            package_family_names: Vec::new(),
14114            product_codes: Vec::new(),
14115            upgrade_codes: Vec::new(),
14116        };
14117        let pins = vec![PinRecord {
14118            package_id: "Contoso.Tool".to_owned(),
14119            version: "*".to_owned(),
14120            source_id: "winget".to_owned(),
14121            pin_type: PinType::Blocking,
14122        }];
14123
14124        assert!(is_upgrade_blocked_by_pin(&item, &pins));
14125    }
14126
14127    #[cfg(windows)]
14128    #[test]
14129    fn parses_msix_package_full_name_into_version_and_family() {
14130        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
14131            .expect("package metadata");
14132
14133        assert_eq!(parsed.version, "0.98.1.0");
14134        assert_eq!(parsed.resource_id, "");
14135        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
14136        assert!(!is_msix_split_resource_package(&parsed.resource_id));
14137    }
14138
14139    #[cfg(windows)]
14140    #[test]
14141    fn classifies_msix_split_resource_package() {
14142        let parsed = parse_msix_package_full_name(
14143            "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe",
14144        )
14145        .expect("package metadata");
14146
14147        assert_eq!(parsed.version, "1.97.17002.0");
14148        assert_eq!(parsed.resource_id, "split.scale-125");
14149        assert_eq!(
14150            parsed.family_name,
14151            "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe"
14152        );
14153        assert!(is_msix_split_resource_package(&parsed.resource_id));
14154    }
14155
14156    #[cfg(windows)]
14157    #[test]
14158    fn recognizes_windows_system_paths() {
14159        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
14160        assert!(!is_windows_system_path(
14161            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
14162        ));
14163    }
14164
14165    #[test]
14166    fn search_ranking_prefers_exact_name_over_tag_match() {
14167        let query = PackageQuery {
14168            query: Some("PowerToys".to_owned()),
14169            ..Default::default()
14170        };
14171        let exact_name = SearchMatch {
14172            source_name: "winget".to_owned(),
14173            source_kind: SourceKind::PreIndexed,
14174            id: "Microsoft.PowerToys".to_owned(),
14175            name: "PowerToys".to_owned(),
14176            moniker: Some("powertoys".to_owned()),
14177            version: Some("0.98.1".to_owned()),
14178            channel: None,
14179            match_criteria: Some("Moniker: powertoys".to_owned()),
14180        };
14181        let tag_match = SearchMatch {
14182            source_name: "winget".to_owned(),
14183            source_kind: SourceKind::PreIndexed,
14184            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
14185            name: "QR Codes for Command Palette".to_owned(),
14186            moniker: None,
14187            version: Some("0.4.0.0".to_owned()),
14188            channel: None,
14189            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
14190        };
14191
14192        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
14193    }
14194
14195    #[test]
14196    fn select_best_text_match_prefers_exact_tag_value() {
14197        let values = vec![
14198            "microsoft-powertoys".to_owned(),
14199            "powertoys-run".to_owned(),
14200            "powertoys".to_owned(),
14201        ];
14202
14203        assert_eq!(
14204            select_best_text_match(values, "PowerToys", false).as_deref(),
14205            Some("powertoys")
14206        );
14207    }
14208
14209    #[test]
14210    fn search_match_criteria_is_blank_for_name_match() {
14211        let query = PackageQuery {
14212            query: Some("PowerToys".to_owned()),
14213            ..Default::default()
14214        };
14215
14216        let result = infer_match_criteria(
14217            "Microsoft.PowerToys",
14218            "PowerToys",
14219            Some("powertoys"),
14220            &query,
14221            SearchSemantics::Many,
14222            |_| Ok(Some("powertoys".to_owned())),
14223            |_| Ok(None),
14224        )
14225        .expect("match criteria");
14226
14227        assert_eq!(result, None);
14228    }
14229
14230    #[test]
14231    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
14232        let query = PackageQuery {
14233            query: Some("PowerToys".to_owned()),
14234            ..Default::default()
14235        };
14236        let unknown_version = SearchMatch {
14237            source_name: "msstore".to_owned(),
14238            source_kind: SourceKind::Rest,
14239            id: "XP89DCGQ3K6VLD".to_owned(),
14240            name: "Microsoft PowerToys".to_owned(),
14241            moniker: None,
14242            version: Some("Unknown".to_owned()),
14243            channel: None,
14244            match_criteria: None,
14245        };
14246        let tag_match = SearchMatch {
14247            source_name: "winget".to_owned(),
14248            source_kind: SourceKind::PreIndexed,
14249            id: "Riri.QRCodeforCmdPal".to_owned(),
14250            name: "QR Code for CmdPal".to_owned(),
14251            moniker: None,
14252            version: Some("0.0.1.0".to_owned()),
14253            channel: None,
14254            match_criteria: Some("Tag: powertoys".to_owned()),
14255        };
14256
14257        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
14258    }
14259
14260    #[test]
14261    fn search_source_fetch_results_ignore_small_display_count() {
14262        let query = PackageQuery {
14263            count: Some(5),
14264            ..Default::default()
14265        };
14266
14267        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
14268        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
14269    }
14270
14271    #[test]
14272    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
14273        let query = PackageQuery {
14274            query: Some("PowerToys".to_owned()),
14275            ..Default::default()
14276        };
14277        let prefix_name = SearchMatch {
14278            source_name: "winget".to_owned(),
14279            source_kind: SourceKind::PreIndexed,
14280            id: "advaith.CurrencyConverterPowerToys".to_owned(),
14281            name: "PowerToys-Run-Currency-Converter".to_owned(),
14282            moniker: None,
14283            version: Some("1.5.4".to_owned()),
14284            channel: None,
14285            match_criteria: None,
14286        };
14287        let exact_tag = SearchMatch {
14288            source_name: "winget".to_owned(),
14289            source_kind: SourceKind::PreIndexed,
14290            id: "Riri.QRCodeforCmdPal".to_owned(),
14291            name: "QR Code for CmdPal".to_owned(),
14292            moniker: None,
14293            version: Some("0.0.1.0".to_owned()),
14294            channel: None,
14295            match_criteria: Some("Tag: powertoys".to_owned()),
14296        };
14297
14298        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
14299    }
14300
14301    #[test]
14302    fn list_sort_prefers_main_package_then_sparse_app() {
14303        let main = InstalledPackage {
14304            name: "PowerToys (Preview) x64".to_owned(),
14305            local_id: r"ARP\User\X64\PowerToys".to_owned(),
14306            installed_version: "0.98.1".to_owned(),
14307            publisher: None,
14308            scope: Some("User".to_owned()),
14309            installer_category: Some("exe".to_owned()),
14310            install_location: None,
14311            package_family_names: Vec::new(),
14312            product_codes: Vec::new(),
14313            upgrade_codes: Vec::new(),
14314            correlated: None,
14315            winget_package_identifier: None,
14316            winget_source_identifier: None,
14317            installed_version_canonical: false,
14318            correlated_requires_explicit_upgrade: false,
14319            correlated_lacks_compatible_installer: false,
14320        };
14321        let sparse = InstalledPackage {
14322            name: "PowerToys.SparseApp".to_owned(),
14323            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14324            installed_version: "0.98.1.0".to_owned(),
14325            publisher: None,
14326            scope: Some("User".to_owned()),
14327            installer_category: Some("msix".to_owned()),
14328            install_location: None,
14329            package_family_names: Vec::new(),
14330            product_codes: Vec::new(),
14331            upgrade_codes: Vec::new(),
14332            correlated: None,
14333            winget_package_identifier: None,
14334            winget_source_identifier: None,
14335            installed_version_canonical: false,
14336            correlated_requires_explicit_upgrade: false,
14337            correlated_lacks_compatible_installer: false,
14338        };
14339        let extension = InstalledPackage {
14340            name: "PowerToys FileLocksmith Context Menu".to_owned(),
14341            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14342            installed_version: "0.98.1.0".to_owned(),
14343            publisher: None,
14344            scope: Some("User".to_owned()),
14345            installer_category: Some("msix".to_owned()),
14346            install_location: None,
14347            package_family_names: Vec::new(),
14348            product_codes: Vec::new(),
14349            upgrade_codes: Vec::new(),
14350            correlated: None,
14351            winget_package_identifier: None,
14352            winget_source_identifier: None,
14353            installed_version_canonical: false,
14354            correlated_requires_explicit_upgrade: false,
14355            correlated_lacks_compatible_installer: false,
14356        };
14357
14358        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
14359        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
14360    }
14361
14362    #[test]
14363    #[cfg(windows)]
14364    fn arp_subkey_name_extracts_registry_subkey() {
14365        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
14366        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
14367    }
14368
14369    #[test]
14370    #[cfg(windows)]
14371    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
14372        let installed = ListMatch {
14373            name: "ShareX".to_owned(),
14374            id: "ShareX.ShareX".to_owned(),
14375            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
14376            installed_version: "19.0.2".to_owned(),
14377            available_version: None,
14378            source_name: Some("winget".to_owned()),
14379            publisher: Some("ShareX Team".to_owned()),
14380            scope: Some("Machine".to_owned()),
14381            installer_category: Some("exe".to_owned()),
14382            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
14383            package_family_names: Vec::new(),
14384            product_codes: Vec::new(),
14385            upgrade_codes: Vec::new(),
14386        };
14387
14388        assert!(registry_entry_matches_installed_package(
14389            "ShareX", "ShareX", None, &installed
14390        ));
14391        assert!(!registry_entry_matches_installed_package(
14392            "ShareX.ShareX",
14393            "ShareX.ShareX",
14394            None,
14395            &installed
14396        ));
14397    }
14398
14399    #[test]
14400    #[cfg(windows)]
14401    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
14402        assert_eq!(
14403            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
14404            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
14405        );
14406        assert_eq!(
14407            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
14408            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
14409        );
14410        assert_eq!(
14411            r#""C:\Program Files\ShareX\unins000.exe""#,
14412            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
14413        );
14414        assert_eq!(
14415            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14416            build_uninstall_command(
14417                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14418                true,
14419                false,
14420            )
14421        );
14422    }
14423
14424    #[test]
14425    #[cfg(windows)]
14426    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
14427        let installed = ListMatch {
14428            name: "lazygit".to_owned(),
14429            id: "JesseDuffield.lazygit".to_owned(),
14430            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
14431            installed_version: "0.61.1".to_owned(),
14432            available_version: None,
14433            source_name: Some("winget".to_owned()),
14434            publisher: Some("Jesse Duffield".to_owned()),
14435            scope: Some("User".to_owned()),
14436            installer_category: Some("exe".to_owned()),
14437            install_location: Some(
14438                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
14439            ),
14440            package_family_names: Vec::new(),
14441            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
14442            upgrade_codes: Vec::new(),
14443        };
14444        let mut request = UninstallRequest::new(PackageQuery {
14445            id: Some("JesseDuffield.lazygit".to_owned()),
14446            install_scope: Some("user".to_owned()),
14447            ..PackageQuery::default()
14448        });
14449        request.mode = InstallerMode::Silent;
14450
14451        assert_eq!(
14452            Some(vec![
14453                "uninstall".to_owned(),
14454                "--id".to_owned(),
14455                "JesseDuffield.lazygit".to_owned(),
14456                "--exact".to_owned(),
14457                "--disable-interactivity".to_owned(),
14458                "--source".to_owned(),
14459                "winget".to_owned(),
14460                "--scope".to_owned(),
14461                "user".to_owned(),
14462                "--silent".to_owned(),
14463            ]),
14464            build_winget_uninstall_arguments(&installed, &request)
14465        );
14466
14467        assert_eq!(
14468            Some(vec![
14469                "uninstall".to_owned(),
14470                "--id".to_owned(),
14471                "JesseDuffield.lazygit".to_owned(),
14472                "--exact".to_owned(),
14473                "--disable-interactivity".to_owned(),
14474                "--source".to_owned(),
14475                "winget".to_owned(),
14476                "--silent".to_owned(),
14477            ]),
14478            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
14479        );
14480    }
14481
14482    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
14483        PackageVersionDataEntry {
14484            version: version.to_owned(),
14485            manifest_relative_path: format!("manifests/x/{version}"),
14486            manifest_hash: "00".repeat(32),
14487            arp_min_version: arp_min.map(str::to_owned),
14488            arp_max_version: arp_max.map(str::to_owned),
14489        }
14490    }
14491
14492    fn v1_version(version: &str, channel: &str) -> V1VersionRow {
14493        V1VersionRow {
14494            version: version.to_owned(),
14495            channel: channel.to_owned(),
14496            pathpart_id: 0,
14497            manifest_hash: None,
14498        }
14499    }
14500
14501    #[derive(Clone)]
14502    struct TestHttpResponse {
14503        status: u16,
14504        body: Vec<u8>,
14505        source_version: Option<String>,
14506        etag: Option<String>,
14507        last_modified: Option<String>,
14508    }
14509
14510    struct TestHttpServer {
14511        addr: SocketAddr,
14512        routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14513        counts: Arc<Mutex<HashMap<String, usize>>>,
14514        running: Arc<AtomicBool>,
14515        handle: Option<JoinHandle<()>>,
14516    }
14517
14518    impl TestHttpServer {
14519        fn start() -> Self {
14520            let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
14521            let addr = listener.local_addr().expect("test HTTP server address");
14522            let routes = Arc::new(Mutex::new(HashMap::new()));
14523            let counts = Arc::new(Mutex::new(HashMap::new()));
14524            let running = Arc::new(AtomicBool::new(true));
14525            let thread_routes = Arc::clone(&routes);
14526            let thread_counts = Arc::clone(&counts);
14527            let thread_running = Arc::clone(&running);
14528            let handle = thread::spawn(move || {
14529                while thread_running.load(AtomicOrdering::SeqCst) {
14530                    let Ok((stream, _)) = listener.accept() else {
14531                        continue;
14532                    };
14533                    handle_test_http_request(stream, &thread_routes, &thread_counts);
14534                }
14535            });
14536
14537            Self {
14538                addr,
14539                routes,
14540                counts,
14541                running,
14542                handle: Some(handle),
14543            }
14544        }
14545
14546        fn url(&self) -> String {
14547            format!("http://{}", self.addr)
14548        }
14549
14550        fn set_bytes(&self, path: &str, body: Vec<u8>) {
14551            self.set_response(
14552                path,
14553                TestHttpResponse {
14554                    status: 200,
14555                    body,
14556                    source_version: None,
14557                    etag: None,
14558                    last_modified: None,
14559                },
14560            );
14561        }
14562
14563        fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
14564            self.set_source_package_with_validators(body, source_version, None, None);
14565        }
14566
14567        fn set_source_package_with_validators(
14568            &self,
14569            body: Vec<u8>,
14570            source_version: &str,
14571            etag: Option<&str>,
14572            last_modified: Option<&str>,
14573        ) {
14574            self.set_response(
14575                "/source.msix",
14576                TestHttpResponse {
14577                    status: 200,
14578                    body,
14579                    source_version: Some(source_version.to_owned()),
14580                    etag: etag.map(str::to_owned),
14581                    last_modified: last_modified.map(str::to_owned),
14582                },
14583            );
14584        }
14585
14586        fn set_status(&self, path: &str, status: u16) {
14587            self.set_response(
14588                path,
14589                TestHttpResponse {
14590                    status,
14591                    body: Vec::new(),
14592                    source_version: None,
14593                    etag: None,
14594                    last_modified: None,
14595                },
14596            );
14597        }
14598
14599        fn set_response(&self, path: &str, response: TestHttpResponse) {
14600            self.routes
14601                .lock()
14602                .expect("test HTTP routes")
14603                .insert(path.to_owned(), response);
14604        }
14605
14606        fn request_count(&self, path: &str) -> usize {
14607            self.counts
14608                .lock()
14609                .expect("test HTTP counts")
14610                .get(path)
14611                .copied()
14612                .unwrap_or_default()
14613        }
14614    }
14615
14616    impl Drop for TestHttpServer {
14617        fn drop(&mut self) {
14618            self.running.store(false, AtomicOrdering::SeqCst);
14619            let _ = TcpStream::connect(self.addr);
14620            if let Some(handle) = self.handle.take() {
14621                let _ = handle.join();
14622            }
14623        }
14624    }
14625
14626    fn handle_test_http_request(
14627        mut stream: TcpStream,
14628        routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14629        counts: &Arc<Mutex<HashMap<String, usize>>>,
14630    ) {
14631        let mut buffer = [0u8; 2048];
14632        let read = stream.read(&mut buffer).unwrap_or(0);
14633        let request = String::from_utf8_lossy(&buffer[..read]);
14634        let path = request
14635            .lines()
14636            .next()
14637            .and_then(|line| line.split_whitespace().nth(1))
14638            .unwrap_or("/");
14639        *counts
14640            .lock()
14641            .expect("test HTTP counts")
14642            .entry(path.to_owned())
14643            .or_default() += 1;
14644        let response = routes.lock().expect("test HTTP routes").get(path).cloned();
14645        let response = response.unwrap_or(TestHttpResponse {
14646            status: 404,
14647            body: Vec::new(),
14648            source_version: None,
14649            etag: None,
14650            last_modified: None,
14651        });
14652        let not_modified = response.status == 200
14653            && (response.etag.as_deref().is_some_and(|etag| {
14654                request_header_value(&request, "If-None-Match")
14655                    .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
14656            }) || response.last_modified.as_deref().is_some_and(|last_modified| {
14657                request_header_value(&request, "If-Modified-Since")
14658                    .is_some_and(|value| value.eq_ignore_ascii_case(last_modified))
14659            }));
14660        let status = if not_modified { 304 } else { response.status };
14661        let body = if not_modified {
14662            &[][..]
14663        } else {
14664            response.body.as_slice()
14665        };
14666        let reason = match status {
14667            200 => "OK",
14668            304 => "Not Modified",
14669            _ => "Not Found",
14670        };
14671        let mut headers = format!(
14672            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
14673            status,
14674            reason,
14675            body.len()
14676        );
14677        if let Some(source_version) = response.source_version {
14678            headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
14679        }
14680        if let Some(etag) = response.etag {
14681            headers.push_str(&format!("ETag: {etag}\r\n"));
14682        }
14683        if let Some(last_modified) = response.last_modified {
14684            headers.push_str(&format!("Last-Modified: {last_modified}\r\n"));
14685        }
14686        headers.push_str("\r\n");
14687        stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
14688        stream.write_all(body).expect("write test HTTP body");
14689    }
14690
14691    fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
14692        request.lines().find_map(|line| {
14693            let (header_name, value) = line.split_once(':')?;
14694            header_name.eq_ignore_ascii_case(name).then(|| value.trim())
14695        })
14696    }
14697
14698    fn test_source_record(source_url: String) -> SourceRecord {
14699        SourceRecord {
14700            name: "test-winget".to_owned(),
14701            kind: SourceKind::PreIndexed,
14702            arg: source_url,
14703            identifier: "Test.Source".to_owned(),
14704            trust_level: "Trusted".to_owned(),
14705            explicit: false,
14706            priority: 0,
14707            last_update: Some(Utc::now()),
14708            source_version: None,
14709            etag: None,
14710            last_modified: None,
14711        }
14712    }
14713
14714    fn open_test_preindexed_repository(
14715        app_root: &Path,
14716        source: &SourceRecord,
14717        interval: Option<Duration>,
14718    ) -> Repository {
14719        fs::create_dir_all(app_root).expect("create app root");
14720        save_store(
14721            app_root,
14722            &SourceStore {
14723                sources: vec![source.clone()],
14724            },
14725        )
14726        .expect("save source store");
14727        Repository::open_with_options(
14728            RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
14729        )
14730        .expect("open repository")
14731    }
14732
14733    fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
14734        let index_path = preindexed_index_path(app_root, source);
14735        if let Some(parent) = index_path.parent() {
14736            fs::create_dir_all(parent).expect("create source state directory");
14737        }
14738        fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
14739    }
14740
14741    #[test]
14742    fn replace_file_failure_keeps_existing_target() {
14743        let root = temp_app_root("replace_file_failure");
14744        fs::create_dir_all(&root).expect("create replace root");
14745        let target = root.join("index.db");
14746        let missing_source = root.join("missing.tmp");
14747        fs::write(&target, b"old-index").expect("write existing target");
14748
14749        let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
14750
14751        assert!(format!("{error:#}").contains("failed to replace"));
14752        assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
14753        let _ = fs::remove_dir_all(root);
14754    }
14755
14756    #[cfg(windows)]
14757    #[test]
14758    fn replace_file_succeeds_while_target_is_open() {
14759        use std::os::windows::fs::OpenOptionsExt;
14760        // FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE — the sharing
14761        // mode SQLite/Turso uses. An atomic MOVEFILE_REPLACE_EXISTING is denied
14762        // while this handle is open, so the stage-aside fallback must take over.
14763        const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
14764
14765        let root = temp_app_root("replace_file_open_target");
14766        fs::create_dir_all(&root).expect("create replace root");
14767        let target = root.join("index.db");
14768        let source = root.join("index.new.tmp");
14769        fs::write(&target, b"old-index").expect("write existing target");
14770        fs::write(&source, b"new-index").expect("write replacement");
14771
14772        let held = OpenOptions::new()
14773            .read(true)
14774            .share_mode(SHARE_ALL)
14775            .open(&target)
14776            .expect("open target with share-delete");
14777
14778        replace_file(&source, &target).expect("stage-aside replace should succeed");
14779
14780        assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
14781        drop(held);
14782        let _ = fs::remove_dir_all(root);
14783    }
14784
14785    fn make_source_package(versions: &[&str]) -> Vec<u8> {
14786        let cursor = Cursor::new(Vec::new());
14787        let mut archive = zip::ZipWriter::new(cursor);
14788        let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
14789        archive
14790            .start_file("Public/index.db", options)
14791            .expect("start index entry");
14792        archive
14793            .write_all(&make_v1_index_bytes(versions))
14794            .expect("write index entry");
14795        archive.finish().expect("finish source package").into_inner()
14796    }
14797
14798    fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
14799        let root = temp_app_root("preindexed_index_bytes");
14800        fs::create_dir_all(&root).expect("create index temp root");
14801        let db_path = root.join("index.db");
14802        let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
14803        let mut sql = String::from(
14804            "CREATE TABLE ids (id TEXT);
14805             CREATE TABLE names (name TEXT);
14806             CREATE TABLE monikers (moniker TEXT);
14807             CREATE TABLE versions (version TEXT);
14808             CREATE TABLE channels (channel TEXT);
14809             CREATE TABLE pathparts (parent INT64, pathpart TEXT);
14810             CREATE TABLE manifest (
14811                 id INT64,
14812                 name INT64,
14813                 moniker INT64,
14814                 version INT64,
14815                 channel INT64,
14816                 pathpart INT64
14817             );
14818             INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
14819             INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
14820             INSERT INTO channels(rowid, channel) VALUES (1, '');
14821             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
14822             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
14823        );
14824        for (index, version) in versions.iter().enumerate() {
14825            let rowid = index + 1;
14826            sql.push_str(&format!(
14827                "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
14828                 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
14829            ));
14830        }
14831        execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
14832        let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
14833            .expect("checkpoint preindexed V1 database");
14834        connection.cacheflush().expect("flush preindexed V1 database");
14835        drop(connection);
14836        let bytes = fs::read(&db_path).expect("read index bytes");
14837        let _ = fs::remove_dir_all(root);
14838        bytes
14839    }
14840
14841    fn test_manifest_yaml(version: &str) -> Vec<u8> {
14842        format!(
14843            "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
14844        )
14845        .into_bytes()
14846    }
14847
14848    fn show_test_package(version: Option<&str>) -> PackageQuery {
14849        PackageQuery {
14850            id: Some("Test.Package".to_owned()),
14851            source: Some("test-winget".to_owned()),
14852            exact: true,
14853            version: version.map(str::to_owned),
14854            ..PackageQuery::default()
14855        }
14856    }
14857
14858    #[test]
14859    fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
14860        let app_root = temp_app_root("preindexed_explicit_refresh");
14861        let server = TestHttpServer::start();
14862        server.set_status("/source2.msix", 404);
14863        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
14864        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14865        let source = test_source_record(server.url());
14866        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14867        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14868
14869        let result = repository
14870            .show(&show_test_package(Some("2.0.0")))
14871            .expect("show after refresh");
14872
14873        assert_eq!(result.manifest.version, "2.0.0");
14874        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
14875        let _ = fs::remove_dir_all(app_root);
14876    }
14877
14878    #[test]
14879    fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
14880        let app_root = temp_app_root("preindexed_explicit_still_missing");
14881        let server = TestHttpServer::start();
14882        server.set_status("/source2.msix", 404);
14883        server.set_source_package(make_source_package(&["1.0.0"]), "same");
14884        let source = test_source_record(server.url());
14885        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14886        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14887
14888        let error = repository
14889            .show(&show_test_package(Some("2.0.0")))
14890            .expect_err("missing explicit version must fail");
14891
14892        assert!(format!("{error:#}").contains("2.0.0"));
14893        let _ = fs::remove_dir_all(app_root);
14894    }
14895
14896    #[test]
14897    fn preindexed_stale_index_refreshes_for_latest_requests() {
14898        let app_root = temp_app_root("preindexed_ttl_refresh");
14899        let server = TestHttpServer::start();
14900        server.set_status("/source2.msix", 404);
14901        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14902        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14903        let mut source = test_source_record(server.url());
14904        source.last_update = Some(Utc::now() - Duration::days(2));
14905        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14906        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14907
14908        let result = repository
14909            .show(&show_test_package(None))
14910            .expect("show latest after TTL refresh");
14911
14912        assert_eq!(result.manifest.version, "2.0.0");
14913        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
14914        let _ = fs::remove_dir_all(app_root);
14915    }
14916
14917    #[test]
14918    fn preindexed_stale_index_not_modified_keeps_local_index() {
14919        let app_root = temp_app_root("preindexed_ttl_not_modified");
14920        let server = TestHttpServer::start();
14921        server.set_status("/source2.msix", 404);
14922        server.set_source_package_with_validators(
14923            make_source_package(&["1.0.0", "2.0.0"]),
14924            "fresh",
14925            Some("\"same-index\""),
14926            Some("Wed, 03 Jun 2026 12:00:00 GMT"),
14927        );
14928        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14929        let mut source = test_source_record(server.url());
14930        source.last_update = Some(Utc::now() - Duration::days(2));
14931        source.etag = Some("\"same-index\"".to_owned());
14932        source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
14933        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14934        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14935
14936        let result = repository
14937            .show(&show_test_package(None))
14938            .expect("show latest from not-modified stale index");
14939
14940        let refreshed_source = &repository.list_sources()[0];
14941        assert_eq!(result.manifest.version, "1.0.0");
14942        assert_eq!(server.request_count("/source.msix"), 1);
14943        assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\""));
14944        assert_eq!(
14945            refreshed_source.last_modified.as_deref(),
14946            Some("Wed, 03 Jun 2026 12:00:00 GMT")
14947        );
14948        let _ = fs::remove_dir_all(app_root);
14949    }
14950
14951    #[test]
14952    fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
14953        let app_root = temp_app_root("preindexed_fresh_mtime");
14954        let server = TestHttpServer::start();
14955        server.set_status("/source2.msix", 404);
14956        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14957        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14958        let mut source = test_source_record(server.url());
14959        source.last_update = Some(Utc::now() - Duration::days(2));
14960        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14961        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
14962
14963        let result = repository
14964            .show(&show_test_package(None))
14965            .expect("show latest from fresh index mtime");
14966
14967        assert_eq!(result.manifest.version, "1.0.0");
14968        assert_eq!(server.request_count("/source.msix"), 0);
14969        let _ = fs::remove_dir_all(app_root);
14970    }
14971
14972    #[test]
14973    fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
14974        let app_root = temp_app_root("preindexed_ttl_fallback");
14975        let server = TestHttpServer::start();
14976        server.set_status("/source2.msix", 404);
14977        server.set_status("/source.msix", 404);
14978        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14979        let mut source = test_source_record(server.url());
14980        source.last_update = Some(Utc::now() - Duration::days(2));
14981        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14982        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14983
14984        let result = repository
14985            .show(&show_test_package(None))
14986            .expect("show latest from stale cache after refresh failure");
14987
14988        assert_eq!(result.manifest.version, "1.0.0");
14989        let _ = fs::remove_dir_all(app_root);
14990    }
14991
14992    #[test]
14993    fn explicit_source_search_failure_is_not_reported_as_no_match() {
14994        let app_root = temp_app_root("preindexed_source_search_failure");
14995        let server = TestHttpServer::start();
14996        server.set_status("/source2.msix", 404);
14997        server.set_status("/source.msix", 404);
14998        let source = test_source_record(server.url());
14999        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15000
15001        let error = repository
15002            .show(&show_test_package(None))
15003            .expect_err("source failure must surface");
15004        let message = format!("{error:#}");
15005
15006        assert!(message.contains("failed to search source 'test-winget'"));
15007        assert!(message.contains("source.msix"));
15008        assert!(!message.contains("no package matched the supplied query"));
15009        let _ = fs::remove_dir_all(app_root);
15010    }
15011
15012    #[test]
15013    fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
15014        let app_root = temp_app_root("preindexed_no_double_refresh");
15015        let server = TestHttpServer::start();
15016        server.set_status("/source2.msix", 404);
15017        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15018        let mut source = test_source_record(server.url());
15019        source.last_update = Some(Utc::now() - Duration::days(2));
15020        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15021        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15022
15023        let error = repository
15024            .show(&show_test_package(Some("3.0.0")))
15025            .expect_err("still-missing explicit version must fail after one stale refresh");
15026
15027        assert!(format!("{error:#}").contains("3.0.0"));
15028        assert_eq!(server.request_count("/source.msix"), 1);
15029        let _ = fs::remove_dir_all(app_root);
15030    }
15031
15032    #[test]
15033    fn select_v2_version_without_explicit_filters_returns_latest() {
15034        let versions = vec![
15035            version_entry("1.0.0", None, None),
15036            version_entry("1.2.0", None, None),
15037            version_entry("1.1.0", None, None),
15038        ];
15039
15040        let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
15041
15042        assert_eq!(selected.version, "1.2.0");
15043    }
15044
15045    #[test]
15046    fn select_v2_version_with_missing_requested_version_throws() {
15047        let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
15048
15049        let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
15050            .expect_err("missing requested version");
15051
15052        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15053        assert!(error.to_string().contains("9.9.9"));
15054    }
15055
15056    #[test]
15057    fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
15058        let versions = vec![version_entry("1.2.0", None, None)];
15059
15060        let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15061            .expect_err("unsupported channel request");
15062
15063        assert!(error.to_string().contains("beta"));
15064        assert!(error.to_string().contains("channel metadata"));
15065    }
15066
15067    #[test]
15068    fn select_v1_version_channel_only_returns_latest_matching_channel() {
15069        let versions = vec![
15070            v1_version("1.2.0", "stable"),
15071            v1_version("1.3.0", "stable"),
15072            v1_version("2.0.0", "preview"),
15073        ];
15074
15075        let selected =
15076            select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
15077
15078        assert_eq!(selected.version, "1.3.0");
15079    }
15080
15081    #[test]
15082    fn select_rest_version_channel_mismatch_throws() {
15083        let versions = vec![
15084            VersionKey {
15085                version: "1.2.0".to_owned(),
15086                channel: "stable".to_owned(),
15087            },
15088            VersionKey {
15089                version: "1.1.0".to_owned(),
15090                channel: "beta".to_owned(),
15091            },
15092        ];
15093
15094        let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15095            .expect_err("missing version and channel pair");
15096
15097        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15098    }
15099
15100    #[test]
15101    fn map_arp_version_returns_catalog_version_inside_range() {
15102        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
15103        // `10.1.826.23019`. compare_version on that against the catalog Version
15104        // `10.0.108` claims installed > available, so without this mapping the
15105        // upgrade was silently dropped.
15106        let entries = vec![
15107            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15108            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15109            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
15110        ];
15111
15112        assert_eq!(
15113            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
15114            Some("10.0.108")
15115        );
15116    }
15117
15118    #[test]
15119    fn map_arp_version_returns_none_when_no_range_matches() {
15120        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
15121        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
15122        // original installed version (and accept that no upgrade can be
15123        // computed) — same as winget's behavior.
15124        let entries = vec![
15125            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15126            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15127        ];
15128
15129        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
15130    }
15131
15132    #[test]
15133    fn map_arp_version_reports_less_than_latest_for_coarse_display_version() {
15134        // Snagit 2025 reports ARP DisplayVersion `25.4.1`, but the catalog's
15135        // latest ARP build is `25.4.1.10325`. winget renders this as
15136        // installed `< 25.4.1` so the latest installer remains applicable.
15137        let entries = vec![
15138            version_entry("25.4.1", Some("25.4.1.10325"), Some("25.4.1.10325")),
15139            version_entry("25.4.0", Some("25.4.0"), Some("25.4.0.8498")),
15140        ];
15141
15142        assert!(map_arp_version_to_catalog(&entries, "25.4.1").is_none());
15143        assert_eq!(
15144            less_than_latest_arp_anchored_version(&entries, "25.4.1").as_deref(),
15145            Some("< 25.4.1")
15146        );
15147        assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none());
15148    }
15149
15150    #[test]
15151    fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() {
15152        // WindowsAppRuntime packages can be serviced beyond the latest winget
15153        // catalog versionData range. winget renders those as `> latest` and
15154        // does not report an available downgrade.
15155        let entries = vec![
15156            version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")),
15157            version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")),
15158        ];
15159
15160        assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none());
15161        assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none());
15162        assert_eq!(
15163            greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(),
15164            Some("> 1.5.8")
15165        );
15166        assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none());
15167
15168        let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))];
15169        assert!(
15170            greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none()
15171        );
15172    }
15173
15174    #[test]
15175    fn map_arp_version_skips_entries_missing_arp_bounds() {
15176        // Older catalog packages predate AppsAndFeaturesEntries and have no
15177        // aMiV/aMaV. Those entries must be ignored — not silently treated as
15178        // an unbounded range.
15179        let entries = vec![
15180            version_entry("9.0.300", None, None),
15181            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
15182        ];
15183
15184        assert_eq!(
15185            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
15186            Some("9.0.117")
15187        );
15188        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
15189    }
15190
15191    #[test]
15192    fn map_arp_version_handles_inclusive_range_endpoints() {
15193        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
15194        // must still map.
15195        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
15196
15197        assert_eq!(
15198            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
15199            Some("3.14.5")
15200        );
15201        assert_eq!(
15202            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
15203            Some("3.14.5")
15204        );
15205        assert_eq!(
15206            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
15207            Some("3.14.5")
15208        );
15209    }
15210
15211    #[test]
15212    fn map_arp_version_ignores_unknown_marker() {
15213        // collect_installed_packages writes the literal "Unknown" when ARP has
15214        // no DisplayVersion. Trying to map that would falsely match the first
15215        // entry whose aMiV..aMaV string-compares to include "Unknown".
15216        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
15217
15218        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
15219        assert!(map_arp_version_to_catalog(&entries, "").is_none());
15220    }
15221
15222    #[test]
15223    fn latest_arp_anchored_skips_internal_versions() {
15224        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
15225        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
15226        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
15227        // it against an installed canonical of `1.8.6` would falsely report
15228        // an upgrade. Returns the highest anchored Version instead.
15229        let entries = vec![
15230            version_entry("8000.836.2153.0", None, None),
15231            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
15232            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
15233            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
15234        ];
15235
15236        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
15237    }
15238
15239    #[test]
15240    fn latest_arp_anchored_returns_none_without_bounds() {
15241        // App Installer's versionData has no aMiV/aMaV — every version is a
15242        // user-facing MSIX Version. Caller must fall back to
15243        // `packages.latest_version` in this case.
15244        let entries = vec![
15245            version_entry("1.28.240.0", None, None),
15246            version_entry("1.27.470.0", None, None),
15247        ];
15248
15249        assert!(latest_arp_anchored_version(&entries).is_none());
15250    }
15251
15252    #[test]
15253    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
15254        let package = InstalledPackage {
15255            name: "AzCopy v10".to_owned(),
15256            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
15257            installed_version: "10.32.2".to_owned(),
15258            publisher: Some("Microsoft".to_owned()),
15259            scope: Some("Machine".to_owned()),
15260            installer_category: Some("exe".to_owned()),
15261            install_location: None,
15262            package_family_names: Vec::new(),
15263            product_codes: Vec::new(),
15264            upgrade_codes: Vec::new(),
15265            correlated: Some(SearchMatch {
15266                source_name: "winget".to_owned(),
15267                source_kind: SourceKind::PreIndexed,
15268                id: "Microsoft.Azure.AZCopy.10".to_owned(),
15269                name: "AzCopy v10".to_owned(),
15270                moniker: None,
15271                version: Some("10.32.3".to_owned()),
15272                channel: None,
15273                match_criteria: None,
15274            }),
15275            winget_package_identifier: None,
15276            winget_source_identifier: None,
15277            installed_version_canonical: false,
15278            correlated_requires_explicit_upgrade: false,
15279            correlated_lacks_compatible_installer: false,
15280        };
15281
15282        let item = list_match_from_installed(package, &[]);
15283
15284        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
15285        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
15286        assert_eq!(item.source_name.as_deref(), Some("winget"));
15287        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
15288    }
15289
15290    #[test]
15291    fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() {
15292        let rows = vec![
15293            ListMatch {
15294                name: "Example 1.0".to_owned(),
15295                id: "Example.Tool".to_owned(),
15296                local_id: r"ARP\Machine\X64\old".to_owned(),
15297                installed_version: "1.0.0".to_owned(),
15298                available_version: Some("3.0.0".to_owned()),
15299                source_name: Some("winget".to_owned()),
15300                publisher: None,
15301                scope: None,
15302                installer_category: None,
15303                install_location: None,
15304                package_family_names: Vec::new(),
15305                product_codes: Vec::new(),
15306                upgrade_codes: Vec::new(),
15307            },
15308            ListMatch {
15309                name: "Example 2.0".to_owned(),
15310                id: "Example.Tool".to_owned(),
15311                local_id: r"ARP\Machine\X64\new".to_owned(),
15312                installed_version: "2.0.0".to_owned(),
15313                available_version: Some("3.0.0".to_owned()),
15314                source_name: Some("winget".to_owned()),
15315                publisher: None,
15316                scope: None,
15317                installer_category: None,
15318                install_location: None,
15319                package_family_names: Vec::new(),
15320                product_codes: Vec::new(),
15321                upgrade_codes: Vec::new(),
15322            },
15323            ListMatch {
15324                name: "Other".to_owned(),
15325                id: "Other.Tool".to_owned(),
15326                local_id: r"ARP\Machine\X64\other".to_owned(),
15327                installed_version: "1.0.0".to_owned(),
15328                available_version: Some("2.0.0".to_owned()),
15329                source_name: Some("winget".to_owned()),
15330                publisher: None,
15331                scope: None,
15332                installer_category: None,
15333                install_location: None,
15334                package_family_names: Vec::new(),
15335                product_codes: Vec::new(),
15336                upgrade_codes: Vec::new(),
15337            },
15338        ];
15339
15340        let result = suppress_duplicate_available_versions(rows);
15341
15342        assert_eq!(result[0].available_version, None);
15343        assert_eq!(result[1].available_version.as_deref(), Some("3.0.0"));
15344        assert_eq!(result[2].available_version.as_deref(), Some("2.0.0"));
15345    }
15346
15347    #[test]
15348    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
15349        let package = InstalledPackage {
15350            name: "Atlassian CLI".to_owned(),
15351            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15352            installed_version: "1.3.18-stable".to_owned(),
15353            publisher: Some("Atlassian".to_owned()),
15354            scope: Some("User".to_owned()),
15355            installer_category: Some("exe".to_owned()),
15356            install_location: None,
15357            package_family_names: Vec::new(),
15358            product_codes: Vec::new(),
15359            upgrade_codes: Vec::new(),
15360            correlated: Some(SearchMatch {
15361                source_name: "winget".to_owned(),
15362                source_kind: SourceKind::PreIndexed,
15363                id: "Atlassian.AtlassianCLI".to_owned(),
15364                name: "Atlassian CLI".to_owned(),
15365                moniker: None,
15366                version: Some("1.3.18-stable".to_owned()),
15367                channel: None,
15368                match_criteria: Some("ProductCode".to_owned()),
15369            }),
15370            winget_package_identifier: None,
15371            winget_source_identifier: None,
15372            installed_version_canonical: false,
15373            correlated_requires_explicit_upgrade: false,
15374            correlated_lacks_compatible_installer: false,
15375        };
15376
15377        let item = list_match_from_installed(package, &[]);
15378
15379        assert_eq!(item.id, "Atlassian.AtlassianCLI");
15380        assert_eq!(
15381            item.local_id,
15382            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
15383        );
15384        assert_ne!(item.id, item.local_id);
15385        assert_eq!(item.source_name.as_deref(), Some("winget"));
15386    }
15387
15388    #[test]
15389    fn list_match_from_installed_derives_winget_portable_identity_from_local_id() {
15390        let package = InstalledPackage {
15391            name: "FFmpeg".to_owned(),
15392            local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15393            installed_version: "8.0".to_owned(),
15394            publisher: Some("Gyan".to_owned()),
15395            scope: Some("User".to_owned()),
15396            installer_category: Some("portable".to_owned()),
15397            install_location: None,
15398            package_family_names: Vec::new(),
15399            product_codes: Vec::new(),
15400            upgrade_codes: Vec::new(),
15401            correlated: None,
15402            winget_package_identifier: None,
15403            winget_source_identifier: None,
15404            installed_version_canonical: false,
15405            correlated_requires_explicit_upgrade: false,
15406            correlated_lacks_compatible_installer: false,
15407        };
15408        let sources = vec![SourceRecord {
15409            name: "winget".to_owned(),
15410            kind: SourceKind::PreIndexed,
15411            arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
15412            identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15413            trust_level: "Trusted".to_owned(),
15414            explicit: false,
15415            priority: 0,
15416            last_update: None,
15417            source_version: None,
15418            etag: None,
15419            last_modified: None,
15420        }];
15421
15422        let item = list_match_from_installed(package, &sources);
15423
15424        assert_eq!(item.id, "Gyan.FFmpeg");
15425        assert_eq!(
15426            item.local_id,
15427            r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe"
15428        );
15429        assert_eq!(item.source_name.as_deref(), Some("winget"));
15430    }
15431
15432    #[test]
15433    fn list_match_from_installed_uses_winget_arp_identity_metadata() {
15434        let package = InstalledPackage {
15435            name: "tessl".to_owned(),
15436            local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15437            installed_version: "0.77.0".to_owned(),
15438            publisher: Some("tessl.io".to_owned()),
15439            scope: Some("User".to_owned()),
15440            installer_category: Some("portable".to_owned()),
15441            install_location: None,
15442            package_family_names: Vec::new(),
15443            product_codes: Vec::new(),
15444            upgrade_codes: Vec::new(),
15445            correlated: None,
15446            winget_package_identifier: Some("tessl.tessl".to_owned()),
15447            winget_source_identifier: Some("api.winget.pro".to_owned()),
15448            installed_version_canonical: false,
15449            correlated_requires_explicit_upgrade: false,
15450            correlated_lacks_compatible_installer: false,
15451        };
15452
15453        let item = list_match_from_installed(package, &[]);
15454
15455        assert_eq!(item.id, "tessl.tessl");
15456        assert_eq!(item.source_name.as_deref(), Some("api.winget.pro"));
15457    }
15458
15459    #[test]
15460    fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() {
15461        let package = InstalledPackage {
15462            name: "tessl".to_owned(),
15463            local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15464            installed_version: "0.77.0".to_owned(),
15465            publisher: Some("tessl.io".to_owned()),
15466            scope: Some("User".to_owned()),
15467            installer_category: Some("portable".to_owned()),
15468            install_location: None,
15469            package_family_names: Vec::new(),
15470            product_codes: Vec::new(),
15471            upgrade_codes: Vec::new(),
15472            correlated: None,
15473            winget_package_identifier: Some("tessl.tessl".to_owned()),
15474            winget_source_identifier: Some("api.winget.pro".to_owned()),
15475            installed_version_canonical: false,
15476            correlated_requires_explicit_upgrade: false,
15477            correlated_lacks_compatible_installer: false,
15478        };
15479        let sources = vec![SourceRecord {
15480            name: "winget.pro".to_owned(),
15481            kind: SourceKind::Rest,
15482            arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(),
15483            identifier: "api.winget.pro".to_owned(),
15484            trust_level: "Trusted".to_owned(),
15485            explicit: false,
15486            priority: 0,
15487            last_update: None,
15488            source_version: None,
15489            etag: None,
15490            last_modified: None,
15491        }];
15492
15493        let item = list_match_from_installed(package, &sources);
15494
15495        assert_eq!(item.id, "tessl.tessl");
15496        assert_eq!(item.source_name.as_deref(), Some("winget.pro"));
15497    }
15498
15499    #[test]
15500    fn list_match_from_installed_keeps_scanning_portable_source_identifiers() {
15501        let package = InstalledPackage {
15502            name: "Example".to_owned(),
15503            local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(),
15504            installed_version: "1.0".to_owned(),
15505            publisher: Some("Vendor".to_owned()),
15506            scope: Some("User".to_owned()),
15507            installer_category: Some("portable".to_owned()),
15508            install_location: None,
15509            package_family_names: Vec::new(),
15510            product_codes: Vec::new(),
15511            upgrade_codes: Vec::new(),
15512            correlated: None,
15513            winget_package_identifier: None,
15514            winget_source_identifier: None,
15515            installed_version_canonical: false,
15516            correlated_requires_explicit_upgrade: false,
15517            correlated_lacks_compatible_installer: false,
15518        };
15519        let sources = vec![
15520            SourceRecord {
15521                name: "long".to_owned(),
15522                kind: SourceKind::PreIndexed,
15523                arg: "https://example.invalid/long".to_owned(),
15524                identifier: "identifier-longer-than-local-id".to_owned(),
15525                trust_level: "Trusted".to_owned(),
15526                explicit: false,
15527                priority: 0,
15528                last_update: None,
15529                source_version: None,
15530                etag: None,
15531                last_modified: None,
15532            },
15533            SourceRecord {
15534                name: "short".to_owned(),
15535                kind: SourceKind::PreIndexed,
15536                arg: "https://example.invalid/short".to_owned(),
15537                identifier: "short".to_owned(),
15538                trust_level: "Trusted".to_owned(),
15539                explicit: false,
15540                priority: 1,
15541                last_update: None,
15542                source_version: None,
15543                etag: None,
15544                last_modified: None,
15545            },
15546        ];
15547
15548        let item = list_match_from_installed(package, &sources);
15549
15550        assert_eq!(item.id, "Vendor.Example");
15551        assert_eq!(item.source_name.as_deref(), Some("short"));
15552    }
15553
15554    #[test]
15555    fn list_response_serialization_keeps_pascal_case_fields() {
15556        let response = ListResponse {
15557            matches: vec![ListMatch {
15558                name: "PowerToys".to_owned(),
15559                id: "Microsoft.PowerToys".to_owned(),
15560                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
15561                installed_version: "0.98.1".to_owned(),
15562                available_version: Some("0.99.0".to_owned()),
15563                source_name: Some("winget".to_owned()),
15564                publisher: Some("Microsoft".to_owned()),
15565                scope: Some("Machine".to_owned()),
15566                installer_category: Some("msi".to_owned()),
15567                install_location: None,
15568                package_family_names: Vec::new(),
15569                product_codes: Vec::new(),
15570                upgrade_codes: Vec::new(),
15571            }],
15572            warnings: Vec::new(),
15573            truncated: false,
15574        };
15575
15576        let value = serde_json::to_value(&response).expect("serialize list response");
15577        let match_value = &value["Matches"][0];
15578
15579        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
15580        assert_eq!(match_value["InstalledVersion"], "0.98.1");
15581        assert_eq!(match_value["AvailableVersion"], "0.99.0");
15582        assert_eq!(match_value["SourceName"], "winget");
15583        assert!(match_value.get("local_id").is_none());
15584        assert!(match_value.get("installed_version").is_none());
15585        assert!(match_value.get("available_version").is_none());
15586        assert!(match_value.get("source_name").is_none());
15587    }
15588
15589    #[test]
15590    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
15591        let response = SearchResponse {
15592            matches: vec![SearchMatch {
15593                source_name: "msstore".to_owned(),
15594                source_kind: SourceKind::Rest,
15595                id: "9WZDNCRFJBMP".to_owned(),
15596                name: "Microsoft To Do".to_owned(),
15597                moniker: None,
15598                version: Some("2.123.456.0".to_owned()),
15599                channel: None,
15600                match_criteria: None,
15601            }],
15602            warnings: Vec::new(),
15603            truncated: false,
15604        };
15605
15606        let value = serde_json::to_value(&response).expect("serialize search response");
15607        let match_value = &value["Matches"][0];
15608
15609        assert_eq!(match_value["Name"], "Microsoft To Do");
15610        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
15611        assert_eq!(match_value["Version"], "2.123.456.0");
15612        assert_eq!(match_value["SourceName"], "msstore");
15613        assert!(match_value.get("source_name").is_none());
15614    }
15615
15616    #[test]
15617    fn versions_result_serialization_keeps_pascal_case_fields() {
15618        let result = VersionsResult {
15619            package: SearchMatch {
15620                source_name: "winget".to_owned(),
15621                source_kind: SourceKind::PreIndexed,
15622                id: "Microsoft.PowerToys".to_owned(),
15623                name: "PowerToys".to_owned(),
15624                moniker: None,
15625                version: Some("0.99.0".to_owned()),
15626                channel: None,
15627                match_criteria: None,
15628            },
15629            versions: vec![
15630                VersionKey {
15631                    version: "0.99.0".to_owned(),
15632                    channel: "stable".to_owned(),
15633                },
15634                VersionKey {
15635                    version: "0.100.0-preview".to_owned(),
15636                    channel: "preview".to_owned(),
15637                },
15638            ],
15639            warnings: Vec::new(),
15640        };
15641
15642        let value = serde_json::to_value(&result).expect("serialize versions result");
15643
15644        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
15645        assert_eq!(value["Versions"][0]["Channel"], "stable");
15646        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
15647        assert_eq!(value["Versions"][1]["Channel"], "preview");
15648        assert!(value.get("versions").is_none());
15649    }
15650
15651    #[test]
15652    fn version_data_parses_arp_bounds_from_winget_payload() {
15653        // Sanity check that aMiV/aMaV deserialize from a real-world
15654        // versionData.mszyml payload shape. Without `default`, packages
15655        // predating AppsAndFeaturesEntries would fail to parse.
15656        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";
15657
15658        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
15659        assert_eq!(document.versions.len(), 2);
15660        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
15661        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
15662        assert!(document.versions[1].arp_min_version.is_none());
15663        assert!(document.versions[1].arp_max_version.is_none());
15664    }
15665
15666    #[test]
15667    fn msix_resource_string_name_resolves_to_catalog_name() {
15668        // App Installer's MSIX manifest stores DisplayName as
15669        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
15670        // know the catalog calls it `App Installer` — show that instead of
15671        // the unresolved placeholder, matching winget's output.
15672        let mut package = InstalledPackage {
15673            name: "ms-resource:appDisplayName".to_owned(),
15674            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
15675            installed_version: "1.28.239.0".to_owned(),
15676            publisher: None,
15677            scope: Some("User".to_owned()),
15678            installer_category: Some("msix".to_owned()),
15679            install_location: None,
15680            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
15681            product_codes: Vec::new(),
15682            upgrade_codes: Vec::new(),
15683            correlated: Some(SearchMatch {
15684                source_name: "winget".to_owned(),
15685                source_kind: SourceKind::PreIndexed,
15686                id: "Microsoft.AppInstaller".to_owned(),
15687                name: "App Installer".to_owned(),
15688                moniker: None,
15689                version: Some("1.28.240.0".to_owned()),
15690                channel: None,
15691                match_criteria: Some("PackageFamilyName".to_owned()),
15692            }),
15693            winget_package_identifier: None,
15694            winget_source_identifier: None,
15695            installed_version_canonical: false,
15696            correlated_requires_explicit_upgrade: false,
15697            correlated_lacks_compatible_installer: false,
15698        };
15699        apply_msix_resource_string_name_fix(&mut package);
15700        assert_eq!(package.name, "App Installer");
15701    }
15702
15703    #[test]
15704    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
15705        // ARP rows can also carry unusual display names; the fix is gated
15706        // on the local_id prefix so a `ms-resource:` literal that somehow
15707        // appears in an ARP DisplayName (extremely unlikely, but possible)
15708        // doesn't get silently rewritten.
15709        let mut package = InstalledPackage {
15710            name: "ms-resource:appDisplayName".to_owned(),
15711            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
15712            installed_version: "1.0".to_owned(),
15713            publisher: None,
15714            scope: Some("Machine".to_owned()),
15715            installer_category: Some("msi".to_owned()),
15716            install_location: None,
15717            package_family_names: Vec::new(),
15718            product_codes: Vec::new(),
15719            upgrade_codes: Vec::new(),
15720            correlated: Some(SearchMatch {
15721                source_name: "winget".to_owned(),
15722                source_kind: SourceKind::PreIndexed,
15723                id: "Some.Package".to_owned(),
15724                name: "Should Not Apply".to_owned(),
15725                moniker: None,
15726                version: Some("1.0".to_owned()),
15727                channel: None,
15728                match_criteria: None,
15729            }),
15730            winget_package_identifier: None,
15731            winget_source_identifier: None,
15732            installed_version_canonical: false,
15733            correlated_requires_explicit_upgrade: false,
15734            correlated_lacks_compatible_installer: false,
15735        };
15736        apply_msix_resource_string_name_fix(&mut package);
15737        assert_eq!(package.name, "ms-resource:appDisplayName");
15738    }
15739
15740    #[test]
15741    fn msix_resource_string_fix_skips_resolved_names() {
15742        // The vast majority of MSIX entries have already-resolved names
15743        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
15744        // those — `meta.name` and the installed name may legitimately
15745        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
15746        let mut package = InstalledPackage {
15747            name: "Microsoft Teams".to_owned(),
15748            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
15749            installed_version: "25290.205.4069.4894".to_owned(),
15750            publisher: None,
15751            scope: Some("User".to_owned()),
15752            installer_category: Some("msix".to_owned()),
15753            install_location: None,
15754            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
15755            product_codes: Vec::new(),
15756            upgrade_codes: Vec::new(),
15757            correlated: Some(SearchMatch {
15758                source_name: "winget".to_owned(),
15759                source_kind: SourceKind::PreIndexed,
15760                id: "Microsoft.Teams".to_owned(),
15761                name: "Microsoft Teams Catalog Name".to_owned(),
15762                moniker: None,
15763                version: Some("26106.1906.4665.7308".to_owned()),
15764                channel: None,
15765                match_criteria: Some("PackageFamilyName".to_owned()),
15766            }),
15767            winget_package_identifier: None,
15768            winget_source_identifier: None,
15769            installed_version_canonical: false,
15770            correlated_requires_explicit_upgrade: false,
15771            correlated_lacks_compatible_installer: false,
15772        };
15773        apply_msix_resource_string_name_fix(&mut package);
15774        assert_eq!(package.name, "Microsoft Teams");
15775    }
15776
15777    #[cfg(windows)]
15778    #[test]
15779    fn unflip_packed_guid_reverses_msi_installer_packing() {
15780        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
15781        // chunks (8/4/4/2*8). This is the encoding used inside
15782        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
15783        // and their value names. Verified against the user's installed
15784        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
15785        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
15786        assert_eq!(
15787            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
15788            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
15789        );
15790        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
15791        // form `A3A70C74FE2431248AD5F8A59570C782`.
15792        assert_eq!(
15793            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
15794            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
15795        );
15796        // Reject non-32-char or non-hex inputs.
15797        assert!(unflip_packed_guid("nothex").is_none());
15798        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
15799        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
15800    }
15801
15802    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
15803        InstalledPackage {
15804            name: format!("{id} install"),
15805            local_id: format!(r"ARP\Machine\X64\{id}"),
15806            installed_version: installed_version.to_owned(),
15807            publisher: None,
15808            scope: Some("Machine".to_owned()),
15809            installer_category: Some("msi".to_owned()),
15810            install_location: None,
15811            package_family_names: Vec::new(),
15812            product_codes: Vec::new(),
15813            upgrade_codes: Vec::new(),
15814            correlated: Some(SearchMatch {
15815                source_name: "winget".to_owned(),
15816                source_kind: SourceKind::PreIndexed,
15817                id: id.to_owned(),
15818                name: id.to_owned(),
15819                moniker: None,
15820                version: Some("99.0.0".to_owned()),
15821                channel: None,
15822                match_criteria: None,
15823            }),
15824            winget_package_identifier: None,
15825            winget_source_identifier: None,
15826            installed_version_canonical: canonical,
15827            correlated_requires_explicit_upgrade: false,
15828            correlated_lacks_compatible_installer: false,
15829        }
15830    }
15831
15832    #[test]
15833    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
15834        // Two installed entries correlate to the same catalog id. One had its
15835        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
15836        // the other did not (e.g. VS-installed .NET SDK whose ARP
15837        // `40.10.18029` doesn't fit any bucket). Without the canonical
15838        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
15839        // wrong row wins and the upgrade silently disappears.
15840        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
15841        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
15842
15843        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
15844        assert_eq!(result.len(), 1);
15845        assert_eq!(result[0].installed_version, "10.0.108");
15846        assert!(result[0].installed_version_canonical);
15847    }
15848
15849    #[test]
15850    fn dedupe_keeps_highest_among_canonical_rows() {
15851        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
15852        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
15853
15854        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
15855        assert_eq!(result.len(), 1);
15856        assert_eq!(result[0].installed_version, "1.7.9");
15857    }
15858
15859    #[test]
15860    fn dedupe_leaves_uncorrelated_rows_alone() {
15861        // Uncorrelated entries don't reach upgrade output anyway (the
15862        // upgrade filter requires correlation), but the dedupe pass must not
15863        // drop them — `pinget list` could share this code path in the future.
15864        let uncorrelated = InstalledPackage {
15865            name: "Foo".to_owned(),
15866            local_id: r"ARP\Machine\X64\Foo".to_owned(),
15867            installed_version: "1.0".to_owned(),
15868            publisher: None,
15869            scope: Some("Machine".to_owned()),
15870            installer_category: Some("exe".to_owned()),
15871            install_location: None,
15872            package_family_names: Vec::new(),
15873            product_codes: Vec::new(),
15874            upgrade_codes: Vec::new(),
15875            correlated: None,
15876            winget_package_identifier: None,
15877            winget_source_identifier: None,
15878            installed_version_canonical: false,
15879            correlated_requires_explicit_upgrade: false,
15880            correlated_lacks_compatible_installer: false,
15881        };
15882        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
15883        assert_eq!(result.len(), 1);
15884    }
15885}