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, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE,
22    IF_NONE_MATCH, 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            .header(CONTENT_TYPE, "application/json")
3125            .body(serde_json::to_vec(&body).context("failed to serialize REST search request")?)
3126            .send()
3127            .context("REST search request failed")?
3128            .error_for_status()
3129            .context("REST search request returned an error")?;
3130        let json =
3131            serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST search response")?)
3132                .context("failed to parse REST search response")?;
3133        let data = json
3134            .get("Data")
3135            .and_then(JsonValue::as_array)
3136            .cloned()
3137            .unwrap_or_default();
3138        let max_results = source_fetch_results(query, semantics);
3139
3140        let mut results = Vec::new();
3141        for item in data {
3142            let package_id = json_string(&item, "PackageIdentifier")
3143                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
3144            let package_name =
3145                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
3146            let mut versions = parse_rest_versions(&item)?;
3147            sort_versions_desc(&mut versions);
3148            let latest = versions
3149                .first()
3150                .cloned()
3151                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
3152
3153            results.push(LocatedMatch {
3154                display: SearchMatch {
3155                    source_name: source.name.clone(),
3156                    source_kind: source.kind,
3157                    id: package_id.clone(),
3158                    name: package_name,
3159                    moniker: json_string(&item, "Moniker"),
3160                    version: Some(latest.version.clone()),
3161                    channel: if latest.channel.is_empty() {
3162                        None
3163                    } else {
3164                        Some(latest.channel.clone())
3165                    },
3166                    match_criteria: rest_match_criteria(&item, query, semantics),
3167                },
3168                source_index,
3169                locator: MatchLocator::Rest { package_id, versions },
3170            });
3171        }
3172
3173        Ok(SearchSourceMatches {
3174            truncated: results.len() >= max_results,
3175            matches: results,
3176        })
3177    }
3178
3179    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
3180        Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
3181    }
3182
3183    fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
3184        let source = self.source_clone(source_index);
3185        let index_path = self.preindexed_index_path(&source);
3186        let mut refresh = PreindexedRefreshOutcome::default();
3187        if !index_path.exists() {
3188            let _ = self.refresh_preindexed_source(source_index, true)?;
3189            refresh.attempted = true;
3190        } else if self.is_preindexed_index_stale(&source, &index_path) {
3191            let lock_key = self.preindexed_refresh_lock_key(&source);
3192            if self.has_recent_preindexed_refresh_attempt(&lock_key) {
3193                refresh.attempted = true;
3194                refresh.reused_recent_attempt = true;
3195            } else {
3196                refresh.attempted = true;
3197                if let Err(error) = self.refresh_preindexed_source(source_index, false) {
3198                    refresh.error = Some(format!("{error:#}"));
3199                }
3200            }
3201        }
3202
3203        Ok(PreindexedOpenConnection {
3204            connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
3205            refresh,
3206        })
3207    }
3208
3209    fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
3210        let Some(interval) = self.pre_indexed_source_auto_update_interval else {
3211            return false;
3212        };
3213
3214        let index_modified = || {
3215            fs::metadata(index_path)
3216                .ok()
3217                .and_then(|metadata| metadata.modified().ok())
3218                .map(DateTime::<Utc>::from)
3219        };
3220
3221        let last_update = match (source.last_update, index_modified()) {
3222            (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
3223            (Some(source_last_update), None) => Some(source_last_update),
3224            (None, Some(index_last_write)) => Some(index_last_write),
3225            (None, None) => None,
3226        };
3227
3228        match last_update {
3229            Some(last_update) => Utc::now() - last_update >= interval,
3230            None => true,
3231        }
3232    }
3233
3234    fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
3235        format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
3236    }
3237
3238    fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3239        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3240            Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3241        })
3242    }
3243
3244    fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3245        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3246            attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3247        })
3248    }
3249
3250    fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
3251        self.preindexed_refresh_attempts.insert(
3252            lock_key,
3253            PreindexedRefreshAttempt {
3254                attempted_at: Utc::now(),
3255                succeeded,
3256            },
3257        );
3258    }
3259
3260    fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
3261        {
3262            let locks = preindexed_refresh_locks()
3263                .read()
3264                .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3265            if let Some(existing) = locks.get(lock_key) {
3266                return Ok(Arc::clone(existing));
3267            }
3268        }
3269
3270        let mut locks = preindexed_refresh_locks()
3271            .write()
3272            .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3273        Ok(Arc::clone(
3274            locks
3275                .entry(lock_key.to_owned())
3276                .or_insert_with(|| Arc::new(Mutex::new(()))),
3277        ))
3278    }
3279
3280    fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
3281        let source = self.source_clone(source_index);
3282        let index_path = self.preindexed_index_path(&source);
3283        let lock_key = self.preindexed_refresh_lock_key(&source);
3284        let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
3285        let _guard = refresh_lock
3286            .lock()
3287            .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
3288
3289        if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
3290            return Ok("source already refreshed".to_owned());
3291        }
3292
3293        let result = self.update_preindexed(source_index);
3294        self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
3295        let detail = result?;
3296        self.save_store()?;
3297        Ok(detail)
3298    }
3299
3300    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
3301        let path_text = path.to_string_lossy().into_owned();
3302        let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
3303            .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
3304        database
3305            .connect()
3306            .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
3307    }
3308
3309    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
3310        let source_snapshot = self.store.sources[source_index].clone();
3311        let state_dir = self.source_state_dir(&source_snapshot);
3312        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
3313        let index_path = self.preindexed_index_path(&source_snapshot);
3314        let has_local_index = index_path.exists();
3315
3316        let mut last_error = None;
3317        for candidate in PREINDEXED_CANDIDATES {
3318            let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate);
3319            if has_local_index
3320                && let Some(detail) =
3321                    self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate)
3322            {
3323                return Ok(detail);
3324            }
3325
3326            let mut request = self.client.get(&url);
3327            if has_local_index {
3328                if let Some(etag) = &source_snapshot.etag
3329                    && let Ok(value) = HeaderValue::from_str(etag)
3330                {
3331                    request = request.header(IF_NONE_MATCH, value);
3332                }
3333                if let Some(last_modified) = &source_snapshot.last_modified
3334                    && let Ok(value) = HeaderValue::from_str(last_modified)
3335                {
3336                    request = request.header(IF_MODIFIED_SINCE, value);
3337                }
3338            }
3339
3340            match request.send() {
3341                Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => {
3342                    let source = &mut self.store.sources[source_index];
3343                    update_preindexed_http_validators(source, response.headers());
3344                    source.last_update = Some(Utc::now());
3345                    return Ok(format!("already up to date from {}", candidate));
3346                }
3347                Ok(response) if response.status().is_success() => {
3348                    let header_version = response
3349                        .headers()
3350                        .get("x-ms-meta-sourceversion")
3351                        .and_then(|value| value.to_str().ok())
3352                        .map(str::to_owned);
3353                    let headers = response.headers().clone();
3354                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
3355                    let payload = bytes.to_vec();
3356                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
3357                        .context("preindexed package did not contain Public/index.db")?;
3358
3359                    fs::write(self.preindexed_package_path(&source_snapshot), &payload)
3360                        .context("failed to persist source package")?;
3361                    let temp_index_path = state_dir.join(format!(
3362                        "index.{}.tmp",
3363                        Utc::now().timestamp_nanos_opt().unwrap_or_default()
3364                    ));
3365                    fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
3366                    if let Err(error) = replace_file(&temp_index_path, &index_path) {
3367                        let _ = fs::remove_file(&temp_index_path);
3368                        return Err(error).context("failed to persist source index");
3369                    }
3370                    // The new index.db is a fresh database; any WAL/SHM sidecars
3371                    // left from the previous one no longer match it and would make
3372                    // SQLite recover against the wrong database. Drop them so the
3373                    // next open starts clean.
3374                    remove_sqlite_sidecars(&index_path);
3375                    let source = &mut self.store.sources[source_index];
3376                    source.last_update = Some(Utc::now());
3377                    source.source_version = header_version;
3378                    update_preindexed_http_validators(source, &headers);
3379                    return Ok(format!("downloaded {}", candidate));
3380                }
3381                Ok(response) => {
3382                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3383                }
3384                Err(error) => {
3385                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3386                }
3387            }
3388        }
3389
3390        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3391    }
3392
3393    fn try_skip_unchanged_preindexed_by_source_version(
3394        &mut self,
3395        source_index: usize,
3396        url: &str,
3397        candidate: &str,
3398    ) -> Option<String> {
3399        let current_version = self.store.sources[source_index].source_version.as_deref()?;
3400        let response = self.client.head(url).send().ok()?;
3401        if !response.status().is_success() {
3402            return None;
3403        }
3404
3405        let header_version = response
3406            .headers()
3407            .get("x-ms-meta-sourceversion")
3408            .and_then(|value| value.to_str().ok())?;
3409        if !header_version.eq_ignore_ascii_case(current_version) {
3410            return None;
3411        }
3412
3413        let source = &mut self.store.sources[source_index];
3414        update_preindexed_http_validators(source, response.headers());
3415        source.last_update = Some(Utc::now());
3416        Some(format!("already up to date from {} (v{})", candidate, header_version))
3417    }
3418
3419    fn update_rest(&mut self, source_index: usize) -> Result<String> {
3420        let info = self.fetch_rest_information(source_index, true)?;
3421        let source = &mut self.store.sources[source_index];
3422        if !info.source_identifier.is_empty() {
3423            source.identifier = info.source_identifier.clone();
3424        }
3425        source.last_update = Some(Utc::now());
3426        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3427        Ok("refreshed information cache".to_owned())
3428    }
3429
3430    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3431        let source = self.source_clone(source_index);
3432        let cache_path = self.rest_information_cache_path(&source);
3433
3434        if cache_path.exists() {
3435            let cache = serde_json::from_slice::<RestInfoCache>(
3436                &fs::read(&cache_path).context("failed to read REST information cache")?,
3437            )
3438            .context("failed to parse REST information cache")?;
3439            if cache.expires_at > Utc::now() {
3440                return Ok(cache.value);
3441            }
3442        }
3443
3444        self.fetch_rest_information(source_index, true)
3445    }
3446
3447    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3448        let source = self.source_clone(source_index);
3449        let url = format!("{}/information", source.arg.trim_end_matches('/'));
3450        let response = self
3451            .client
3452            .get(url)
3453            .send()
3454            .context("REST information request failed")?
3455            .error_for_status()
3456            .context("REST information request returned an error")?;
3457
3458        let max_age = cache_control_max_age(&response);
3459        let json =
3460            serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST information response")?)
3461                .context("failed to parse REST information response")?;
3462        let data = json
3463            .get("Data")
3464            .cloned()
3465            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3466        let info = serde_json::from_value::<RestInformation>(data)
3467            .context("failed to deserialize REST information payload")?;
3468
3469        if persist {
3470            let cache = RestInfoCache {
3471                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3472                value: info.clone(),
3473            };
3474            write_json(self.rest_information_cache_path(&source), &cache)?;
3475        }
3476
3477        Ok(info)
3478    }
3479
3480    fn load_v2_version_data(
3481        &self,
3482        source: &SourceRecord,
3483        package_rowid: i64,
3484        package_hash: &str,
3485    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3486        let connection = Self::open_sqlite_connection(self.preindexed_index_path(source))
3487            .context("failed to reopen preindexed index for V2 version data")?;
3488        let package_hash = package_hash.to_ascii_lowercase();
3489        let package_id = query_optional_value(
3490            &connection,
3491            "SELECT id FROM packages WHERE rowid = ?1",
3492            vec![SqlValue::Integer(package_rowid)],
3493            |row| row_string(row, 0),
3494        )?
3495        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3496        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3497        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3498        let yaml = decompress_mszyml(&file.bytes)?;
3499        let document =
3500            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3501        Ok((document.versions, file.path))
3502    }
3503
3504    fn get_cached_source_file(
3505        &self,
3506        bucket: &str,
3507        source: &SourceRecord,
3508        relative_path: &str,
3509        expected_hash: Option<&str>,
3510    ) -> Result<CachedBytes> {
3511        let normalized_relative = relative_path.replace('\\', "/");
3512        let cache_path =
3513            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3514
3515        if cache_path.exists() {
3516            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3517            if hash_matches(expected_hash, &cached) {
3518                return Ok(CachedBytes {
3519                    path: cache_path,
3520                    bytes: cached,
3521                });
3522            }
3523        }
3524
3525        let url = format!(
3526            "{}/{}",
3527            source.arg.trim_end_matches('/'),
3528            normalized_relative.trim_start_matches('/')
3529        );
3530        let response = self
3531            .client
3532            .get(url)
3533            .send()
3534            .context("failed to fetch source file")?
3535            .error_for_status()
3536            .context("source file request returned an error")?;
3537        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3538
3539        if let Some(hash) = expected_hash {
3540            verify_hash(hash, &bytes)?;
3541        }
3542
3543        if let Some(parent) = cache_path.parent() {
3544            fs::create_dir_all(parent).context("failed to create cache directory")?;
3545        }
3546        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3547
3548        Ok(CachedBytes {
3549            path: cache_path,
3550            bytes,
3551        })
3552    }
3553
3554    fn get_or_fetch_rest_manifest(
3555        &mut self,
3556        source: &SourceRecord,
3557        package_id: &str,
3558        version: &str,
3559        channel: &str,
3560    ) -> Result<(Vec<u8>, PathBuf)> {
3561        let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel);
3562        if cache_path.exists() {
3563            return Ok((
3564                fs::read(&cache_path).context("failed to read cached REST manifest")?,
3565                cache_path,
3566            ));
3567        }
3568
3569        let info = self.load_rest_information_by_name(&source.name)?;
3570        let contract = choose_contract(&info.server_supported_versions)
3571            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3572        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3573
3574        let mut params = vec![("Version", version.to_owned())];
3575        if !channel.is_empty() {
3576            params.push(("Channel", channel.to_owned()));
3577        }
3578        if info
3579            .required_query_parameters
3580            .iter()
3581            .any(|value| value.eq_ignore_ascii_case("Market"))
3582        {
3583            params.push(("Market", default_market()));
3584        }
3585
3586        let response = self
3587            .client
3588            .get(url)
3589            .header("Version", contract)
3590            .query(&params)
3591            .send()
3592            .context("REST manifest request failed")?
3593            .error_for_status()
3594            .context("REST manifest request returned an error")?;
3595        let bytes = response
3596            .bytes()
3597            .context("failed to read REST manifest response")?
3598            .to_vec();
3599
3600        if let Some(parent) = cache_path.parent() {
3601            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3602        }
3603        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3604
3605        Ok((bytes, cache_path))
3606    }
3607
3608    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3609        let index = self
3610            .store
3611            .sources
3612            .iter()
3613            .position(|source| source.name.eq_ignore_ascii_case(name))
3614            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3615        self.load_rest_information(index)
3616    }
3617
3618    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3619        if let Some(name) = source_name {
3620            if let Some(index) = self
3621                .store
3622                .sources
3623                .iter()
3624                .position(|source| source.name.eq_ignore_ascii_case(name))
3625            {
3626                return Ok(vec![index]);
3627            }
3628
3629            let index = self
3630                .store
3631                .sources
3632                .iter()
3633                .position(|source| source.identifier.eq_ignore_ascii_case(name))
3634                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3635            return Ok(vec![index]);
3636        }
3637
3638        let mut indexes: Vec<_> = self
3639            .store
3640            .sources
3641            .iter()
3642            .enumerate()
3643            .filter(|(_, source)| !source.explicit)
3644            .collect();
3645        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3646            right_source
3647                .priority
3648                .cmp(&left_source.priority)
3649                .then_with(|| left_index.cmp(right_index))
3650        });
3651        Ok(indexes.into_iter().map(|(index, _)| index).collect())
3652    }
3653
3654    fn source_clone(&self, index: usize) -> SourceRecord {
3655        self.store.sources[index].clone()
3656    }
3657
3658    fn refresh_system_winget_sources(&mut self) -> Result<()> {
3659        self.store = match self.source_mode {
3660            EffectiveSourceMode::Private => self.store.clone(),
3661            EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?,
3662            EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?,
3663        };
3664        Ok(())
3665    }
3666
3667    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3668        self.refresh_system_winget_sources()?;
3669        let selected_names: Vec<_> = if source_name.is_none() {
3670            self.store.sources.iter().map(|source| source.name.clone()).collect()
3671        } else {
3672            self.resolve_source_indexes(source_name)?
3673                .into_iter()
3674                .map(|index| self.store.sources[index].name.clone())
3675                .collect()
3676        };
3677
3678        let detail = system_winget_update_sources(source_name)?;
3679        self.refresh_system_winget_sources()?;
3680
3681        Ok(selected_names
3682            .into_iter()
3683            .filter_map(|name| {
3684                self.store
3685                    .sources
3686                    .iter()
3687                    .find(|source| source.name.eq_ignore_ascii_case(&name))
3688                    .map(|source| SourceUpdateResult {
3689                        name: source.name.clone(),
3690                        kind: source.kind,
3691                        detail: detail.clone(),
3692                    })
3693            })
3694            .collect())
3695    }
3696}
3697
3698#[derive(Debug)]
3699struct CachedBytes {
3700    path: PathBuf,
3701    bytes: Vec<u8>,
3702}
3703
3704#[derive(Debug, Clone)]
3705struct V1SearchRow {
3706    manifest_rowid: i64,
3707    package_rowid: i64,
3708    version: String,
3709    channel: String,
3710    id: String,
3711    name: String,
3712    moniker: Option<String>,
3713    match_criteria: Option<String>,
3714}
3715
3716#[derive(Debug, Clone)]
3717struct V1VersionRow {
3718    version: String,
3719    channel: String,
3720    pathpart_id: i64,
3721    manifest_hash: Option<String>,
3722}
3723
3724#[derive(Debug, Clone)]
3725struct V2SearchRow {
3726    package_rowid: i64,
3727    package_hash: String,
3728    id: String,
3729    name: String,
3730    moniker: Option<String>,
3731    version: String,
3732    match_criteria: Option<String>,
3733}
3734
3735fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3736    query.query.is_some()
3737        || query.id.is_some()
3738        || query.name.is_some()
3739        || query.moniker.is_some()
3740        || query.tag.is_some()
3741        || query.command.is_some()
3742        || query.source.is_some()
3743}
3744
3745fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3746    PackageQuery {
3747        query: query.query.clone(),
3748        id: query.id.clone(),
3749        name: query.name.clone(),
3750        moniker: query.moniker.clone(),
3751        tag: query.tag.clone(),
3752        command: query.command.clone(),
3753        source: query.source.clone(),
3754        count: Some(LIST_LOOKUP_MAX_RESULTS),
3755        exact: query.exact,
3756        version: None,
3757        channel: None,
3758        locale: None,
3759        installer_type: None,
3760        installer_architecture: None,
3761        platform: None,
3762        os_version: None,
3763        install_scope: query.install_scope.clone(),
3764    }
3765}
3766
3767fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3768    query.query.is_some() || query.id.is_some() || query.name.is_some()
3769}
3770
3771fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3772    if is_greater_than_version_marker(&package.installed_version) {
3773        return false;
3774    }
3775    package
3776        .correlated
3777        .as_ref()
3778        .and_then(|candidate| candidate.version.as_deref())
3779        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3780}
3781
3782fn is_greater_than_version_marker(version: &str) -> bool {
3783    version.trim_start().starts_with('>')
3784}
3785
3786fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3787    package.installed_version.eq_ignore_ascii_case("Unknown")
3788}
3789
3790fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3791    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
3792    // Discord). Explicit `--id` filters surface them regardless.
3793    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3794        return false;
3795    }
3796    // Hide rows whose latest catalog version has no installer the host can
3797    // run — winget refuses to upgrade past an unusable architecture.
3798    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3799        return false;
3800    }
3801    installed_package_has_upgrade(package)
3802        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3803}
3804
3805/// Collapses installed entries that correlate to the same catalog package id
3806/// (case-insensitive within a source) down to a single representative — the
3807/// one with the highest installed version. Uncorrelated entries pass through
3808/// untouched; they can't appear in `upgrade` output anyway (the upgrade
3809/// filter requires a correlation), so they're effectively a no-op here.
3810fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3811    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3812    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3813
3814    for package in packages {
3815        match package.correlated.as_ref() {
3816            Some(candidate) => {
3817                let key = (
3818                    candidate.id.to_ascii_lowercase(),
3819                    candidate.source_name.to_ascii_lowercase(),
3820                );
3821                let keep_new = match by_id.get(&key) {
3822                    Some(existing) => {
3823                        // Prefer rows whose `installed_version` was remapped to
3824                        // a catalog Version (via aMiV/aMaV). Comparing a raw
3825                        // ARP DisplayVersion like `40.10.18029` against the
3826                        // catalog's `10.0.300` is meaningless and would also
3827                        // hide the row that legitimately upgrades to it.
3828                        match (
3829                            package.installed_version_canonical,
3830                            existing.installed_version_canonical,
3831                        ) {
3832                            (true, false) => true,
3833                            (false, true) => false,
3834                            _ => {
3835                                compare_version(&package.installed_version, &existing.installed_version)
3836                                    == Ordering::Greater
3837                            }
3838                        }
3839                    }
3840                    None => true,
3841                };
3842                if keep_new {
3843                    by_id.insert(key, package);
3844                }
3845            }
3846            None => uncorrelated.push(package),
3847        }
3848    }
3849
3850    let mut result = uncorrelated;
3851    result.extend(by_id.into_values());
3852    result
3853}
3854
3855fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3856    let mut source_specific = None;
3857    let mut source_agnostic = None;
3858
3859    for pin in pins {
3860        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3861            continue;
3862        }
3863
3864        if pin.source_id.is_empty() {
3865            if source_agnostic.is_none() {
3866                source_agnostic = Some(pin);
3867            }
3868        } else if item
3869            .source_name
3870            .as_deref()
3871            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3872        {
3873            source_specific = Some(pin);
3874            break;
3875        }
3876    }
3877
3878    source_specific.or(source_agnostic)
3879}
3880
3881fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3882    let Some(available_version) = item.available_version.as_deref() else {
3883        return false;
3884    };
3885    let Some(pin) = find_applicable_pin(item, pins) else {
3886        return false;
3887    };
3888
3889    match pin.pin_type {
3890        PinType::Blocking => true,
3891        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3892    }
3893}
3894
3895fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3896    if pattern.trim().is_empty() || pattern == "*" {
3897        return true;
3898    }
3899
3900    if !pattern.contains('*') {
3901        return version.eq_ignore_ascii_case(pattern);
3902    }
3903
3904    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3905        let prefix = &pattern[..pattern.len() - 1];
3906        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3907    }
3908
3909    version.eq_ignore_ascii_case(pattern)
3910}
3911
3912fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch {
3913    let available_version = package.correlated.as_ref().and_then(|candidate| {
3914        candidate.version.as_ref().and_then(|candidate_version| {
3915            if !is_greater_than_version_marker(&package.installed_version)
3916                && (installed_package_has_unknown_version(&package)
3917                    || compare_version(candidate_version, &package.installed_version) == Ordering::Greater)
3918            {
3919                Some(candidate_version.clone())
3920            } else {
3921                None
3922            }
3923        })
3924    });
3925    let mut source_name = package
3926        .correlated
3927        .as_ref()
3928        .map(|candidate| candidate.source_name.clone())
3929        .filter(|value| !value.is_empty());
3930    let mut id = package
3931        .correlated
3932        .as_ref()
3933        .map(|candidate| candidate.id.clone())
3934        .unwrap_or_else(|| package.local_id.clone());
3935
3936    if source_name.is_none()
3937        && let Some(package_id) = package
3938            .winget_package_identifier
3939            .as_deref()
3940            .filter(|value| !value.trim().is_empty())
3941    {
3942        id = package_id.to_owned();
3943        source_name = package
3944            .winget_source_identifier
3945            .as_deref()
3946            .filter(|value| !value.trim().is_empty())
3947            .map(|identifier| source_name_from_identifier(identifier, sources));
3948    }
3949
3950    if source_name.is_none()
3951        && let Some((local_package_id, local_source_name)) =
3952            winget_package_identity_from_local_id(&package.local_id, sources)
3953    {
3954        id = local_package_id;
3955        source_name = Some(local_source_name);
3956    }
3957
3958    ListMatch {
3959        name: package.name,
3960        id,
3961        local_id: package.local_id,
3962        installed_version: package.installed_version,
3963        available_version,
3964        source_name,
3965        publisher: package.publisher,
3966        scope: package.scope,
3967        installer_category: package.installer_category,
3968        install_location: package.install_location,
3969        package_family_names: package.package_family_names,
3970        product_codes: package.product_codes,
3971        upgrade_codes: package.upgrade_codes,
3972    }
3973}
3974
3975fn suppress_duplicate_available_versions(matches: Vec<ListMatch>) -> Vec<ListMatch> {
3976    let mut grouped: HashMap<(String, String), Vec<usize>> = HashMap::new();
3977    for (index, item) in matches.iter().enumerate() {
3978        let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else {
3979            continue;
3980        };
3981        grouped
3982            .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
3983            .or_default()
3984            .push(index);
3985    }
3986
3987    let mut keep = BTreeSet::new();
3988    for indexes in grouped.values().filter(|indexes| indexes.len() > 1) {
3989        let Some(best) = indexes
3990            .iter()
3991            .map(|index| matches[*index].installed_version.as_str())
3992            .max_by(|left, right| compare_version(left, right))
3993        else {
3994            continue;
3995        };
3996        for index in indexes {
3997            if compare_version(&matches[*index].installed_version, best) == Ordering::Equal {
3998                keep.insert(*index);
3999            }
4000        }
4001    }
4002
4003    matches
4004        .into_iter()
4005        .enumerate()
4006        .map(|(index, mut item)| {
4007            if item.available_version.is_some() && !keep.contains(&index) {
4008                let is_duplicate = item
4009                    .source_name
4010                    .as_ref()
4011                    .map(|source_name| {
4012                        grouped
4013                            .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4014                            .is_some_and(|indexes| indexes.len() > 1)
4015                    })
4016                    .unwrap_or(false);
4017                if is_duplicate {
4018                    item.available_version = None;
4019                }
4020            }
4021            item
4022        })
4023        .collect()
4024}
4025
4026fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String {
4027    sources
4028        .iter()
4029        .find(|source| source.identifier.eq_ignore_ascii_case(identifier))
4030        .map(|source| source.name.clone())
4031        .unwrap_or_else(|| identifier.to_owned())
4032}
4033
4034fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> {
4035    let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1);
4036    for source in sources {
4037        let source_suffix = format!("_{}", source.identifier);
4038        let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else {
4039            continue;
4040        };
4041        if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) {
4042            continue;
4043        }
4044        if source_identifier_start_index <= package_id_start_index {
4045            return None;
4046        }
4047        let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned();
4048        if package_id.trim().is_empty() {
4049            return None;
4050        }
4051        return Some((package_id, source.name.clone()));
4052    }
4053    None
4054}
4055
4056fn list_sort_weight(package: &InstalledPackage) -> usize {
4057    if package.local_id.starts_with("ARP\\") {
4058        0
4059    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
4060        1
4061    } else {
4062        2
4063    }
4064}
4065
4066fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
4067    let correlated = package.correlated.as_ref();
4068
4069    if let Some(value) = &query.id {
4070        let local_match = matches_text(&package.local_id, value, query.exact);
4071        let correlated_match = correlated
4072            .map(|candidate| matches_text(&candidate.id, value, query.exact))
4073            .unwrap_or(false);
4074        if !local_match && !correlated_match {
4075            return false;
4076        }
4077    }
4078
4079    if let Some(value) = &query.name
4080        && !matches_text(&package.name, value, query.exact)
4081    {
4082        return false;
4083    }
4084
4085    if let Some(value) = &query.product_code
4086        && !package
4087            .product_codes
4088            .iter()
4089            .any(|code| code.eq_ignore_ascii_case(value))
4090    {
4091        return false;
4092    }
4093
4094    if let Some(value) = &query.version
4095        && !package.installed_version.eq_ignore_ascii_case(value)
4096    {
4097        return false;
4098    }
4099
4100    if let Some(value) = &query.query {
4101        let local_match =
4102            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
4103        let correlated_match = correlated
4104            .map(|candidate| {
4105                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
4106            })
4107            .unwrap_or(false);
4108        if !local_match && !correlated_match {
4109            return false;
4110        }
4111    }
4112
4113    if let Some(source) = &query.source
4114        && correlated
4115            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
4116            .unwrap_or(true)
4117    {
4118        return false;
4119    }
4120
4121    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
4122        return false;
4123    }
4124
4125    true
4126}
4127
4128fn correlate_installed_package(
4129    package: &InstalledPackage,
4130    candidates: &[SearchMatch],
4131    allow_loose_name_match: bool,
4132) -> Option<SearchMatch> {
4133    // MSIX entries can only be correlated through their PackageFamilyName.
4134    // That lookup happens earlier in `correlate_installed_via_index`; if it
4135    // didn't find a match, name-based fallback is wrong — the catalog
4136    // doesn't carry this MSIX's identity, so any name collision (e.g. the
4137    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
4138    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
4139    // doesn't make.
4140    if package.local_id.starts_with("MSIX\\") {
4141        return None;
4142    }
4143
4144    let installed_name = normalize_correlation_name(&package.name);
4145    let installed_name_lower = package.name.to_ascii_lowercase();
4146    let candidate_names = correlation_name_candidates(&package.name);
4147
4148    let mut scored: Vec<(i32, SearchMatch)> = candidates
4149        .iter()
4150        .filter_map(|candidate| {
4151            let candidate_name = normalize_correlation_name(&candidate.name);
4152            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
4153                1000
4154            } else if candidate_names.iter().any(|name| {
4155                let normalized = normalize_correlation_name(name);
4156                normalized == candidate_name
4157            }) {
4158                900
4159            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
4160                700
4161            } else {
4162                0
4163            };
4164
4165            if base_score == 0 {
4166                return None;
4167            }
4168
4169            // Tiebreaker so we pick the *right* candidate when multiple catalog
4170            // entries collapse to the same normalized name or all loose-match a
4171            // long installed name. Two failure modes this addresses:
4172            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
4173            //     alphanumeric filter strips `+`/`-`), so both score 900 and
4174            //     `max_by_key` arbitrarily picked the latter.
4175            //   • Loose substring match picks up `Studio` inside
4176            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
4177            //     `Microsoft.DotNet.SDK.10` when both score 700.
4178            // The bonus rewards catalog names that appear verbatim in the
4179            // installed display name — strongly when they anchor the start,
4180            // weaker when they're embedded.
4181            let candidate_lower = candidate.name.to_ascii_lowercase();
4182            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
4183                100
4184            } else if installed_name_lower.contains(&candidate_lower) {
4185                50
4186            } else {
4187                0
4188            };
4189
4190            Some((base_score + prefix_bonus, candidate.clone()))
4191        })
4192        .collect();
4193
4194    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
4195    let mut iter = scored.into_iter();
4196    let (top_score, top_match) = iter.next()?;
4197
4198    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
4199    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
4200    // normalize their names to `git` and tie at the same score — the only
4201    // signal that disambiguates them, publisher, isn't part of this scoring
4202    // function. winget refuses the correlation in this case (the install
4203    // shows up with an empty Source); we do the same to avoid manufacturing
4204    // a fake upgrade against the wrong package.
4205    if let Some((next_score, _)) = iter.next()
4206        && next_score == top_score
4207    {
4208        return None;
4209    }
4210    Some(top_match)
4211}
4212
4213fn correlation_name_candidates(name: &str) -> Vec<String> {
4214    let mut candidates = Vec::new();
4215    candidates.push(name.trim().to_owned());
4216
4217    let mut trimmed = name.trim().to_owned();
4218    if let Some(index) = trimmed.find(" (") {
4219        trimmed.truncate(index);
4220    }
4221
4222    let mut words = Vec::new();
4223    for token in trimmed.split_whitespace() {
4224        let lower = token
4225            .trim_matches(|ch: char| ch == '(' || ch == ')')
4226            .to_ascii_lowercase();
4227        if !words.is_empty()
4228            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
4229        {
4230            break;
4231        }
4232        words.push(token);
4233    }
4234
4235    if !words.is_empty() {
4236        candidates.push(words.join(" "));
4237    }
4238
4239    candidates.sort();
4240    candidates.dedup();
4241    candidates
4242}
4243
4244fn normalize_correlation_name(value: &str) -> String {
4245    value
4246        .chars()
4247        .filter(|ch| ch.is_ascii_alphanumeric())
4248        .flat_map(|ch| ch.to_lowercase())
4249        .collect()
4250}
4251
4252struct V2PackageMetadata {
4253    rowid: i64,
4254    id: String,
4255    name: String,
4256    moniker: Option<String>,
4257    latest_version: String,
4258    package_hash: String,
4259}
4260
4261/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
4262/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
4263/// and the field that produced the match. This is winget's primary correlation
4264/// path; falling back to display-name matching is only correct for ARP entries
4265/// that lack all three identifiers.
4266fn lookup_identity_match_v2(
4267    connection: &Connection,
4268    package: &InstalledPackage,
4269) -> Result<Option<(i64, &'static str)>> {
4270    for pfn in &package.package_family_names {
4271        if let Some(rowid) = query_optional_value(
4272            connection,
4273            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
4274            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
4275            |row| row_i64(row, 0),
4276        )? {
4277            return Ok(Some((rowid, "PackageFamilyName")));
4278        }
4279    }
4280    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
4281    // identity — it stays stable across versions, while ProductCode changes
4282    // every release. When a single ProductCode happens to also be listed
4283    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
4284    // installer rows from earlier LTS-line ProductCodes, while the v24
4285    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
4286    // the upgrade-side match represents the user's "package family" — the
4287    // one winget uses.
4288    for code in &package.upgrade_codes {
4289        if let Some(rowid) = query_optional_value(
4290            connection,
4291            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
4292            vec![SqlValue::Text(code.to_ascii_lowercase())],
4293            |row| row_i64(row, 0),
4294        )? {
4295            return Ok(Some((rowid, "UpgradeCode")));
4296        }
4297    }
4298    for code in &package.product_codes {
4299        if let Some(rowid) = query_optional_value(
4300            connection,
4301            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
4302            vec![SqlValue::Text(code.to_ascii_lowercase())],
4303            |row| row_i64(row, 0),
4304        )? {
4305            return Ok(Some((rowid, "ProductCode")));
4306        }
4307    }
4308    Ok(None)
4309}
4310
4311fn installed_db_identity_tables_present(connection: &Connection) -> Result<bool> {
4312    Ok(table_exists(connection, "manifest")?
4313        && table_exists(connection, "ids")?
4314        && table_exists(connection, "names")?
4315        && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?)
4316            || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?)
4317            || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?)))
4318}
4319
4320fn table_exists(connection: &Connection, name: &str) -> Result<bool> {
4321    query_optional_value(
4322        connection,
4323        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
4324        vec![SqlValue::Text(name.to_owned())],
4325        |row| row_i64(row, 0),
4326    )
4327    .map(|value| value.is_some())
4328}
4329
4330fn lookup_installed_db_identity_match(
4331    connection: &Connection,
4332    package: &InstalledPackage,
4333) -> Result<Option<(String, String, &'static str)>> {
4334    for pfn in &package.package_family_names {
4335        if let Some(hit) =
4336            lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")?
4337        {
4338            return Ok(Some(hit));
4339        }
4340    }
4341    for code in &package.upgrade_codes {
4342        if let Some(hit) = lookup_installed_db_identity_match_value(
4343            connection,
4344            "upgradecodes",
4345            "upgradecodes_map",
4346            "upgradecode",
4347            code,
4348            "UpgradeCode",
4349        )? {
4350            return Ok(Some(hit));
4351        }
4352    }
4353    for code in &package.product_codes {
4354        if let Some(hit) = lookup_installed_db_identity_match_value(
4355            connection,
4356            "productcodes",
4357            "productcodes_map",
4358            "productcode",
4359            code,
4360            "ProductCode",
4361        )? {
4362            return Ok(Some(hit));
4363        }
4364    }
4365    Ok(None)
4366}
4367
4368fn lookup_installed_db_identity_match_value(
4369    connection: &Connection,
4370    value_table: &'static str,
4371    map_table: &'static str,
4372    value_column: &'static str,
4373    value: &str,
4374    matched_by: &'static str,
4375) -> Result<Option<(String, String, &'static str)>> {
4376    let sql = format!(
4377        "SELECT ids.id, names.name \
4378         FROM {map_table} map \
4379         JOIN {value_table} value ON map.{value_column} = value.rowid \
4380         JOIN manifest ON map.manifest = manifest.rowid \
4381         JOIN ids ON manifest.id = ids.rowid \
4382         JOIN names ON manifest.name = names.rowid \
4383         WHERE value.{value_column} = ?1 \
4384         LIMIT 1"
4385    );
4386    query_optional_value(
4387        connection,
4388        &sql,
4389        vec![SqlValue::Text(value.to_ascii_lowercase())],
4390        |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)),
4391    )
4392}
4393
4394/// Resolves a v2 package by its catalog id back to its rowid + hash so the
4395/// post-correlation enrichment pass can load versionData for rows that
4396/// correlated through the name-based fallback (which throws the rowid away).
4397fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
4398    query_optional_value(
4399        connection,
4400        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
4401        vec![SqlValue::Text(package_id.to_owned())],
4402        |row| {
4403            Ok(V2PackageMetadata {
4404                rowid: row_i64(row, 0)?,
4405                id: row_string(row, 1)?,
4406                name: row_string(row, 2)?,
4407                moniker: row_opt_string(row, 3)?,
4408                latest_version: row_string(row, 4)?,
4409                package_hash: row_hex_string(row, 5)?,
4410            })
4411        },
4412    )
4413}
4414
4415/// MSIX entries whose Windows resource-string display name (e.g.
4416/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
4417/// would otherwise leak that placeholder into pinget's output. winget
4418/// resolves it via `Windows.Management.Deployment` and shows the catalog's
4419/// `PackageName`; we mirror that by substituting the correlated catalog
4420/// name once the package is correlated, which is the parity-faithful
4421/// substitute we can produce without taking a WinRT dependency.
4422fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
4423    if !package.local_id.starts_with("MSIX\\") {
4424        return;
4425    }
4426    if !package.name.starts_with("ms-resource:") {
4427        return;
4428    }
4429    let Some(candidate) = package.correlated.as_ref() else {
4430        return;
4431    };
4432    if !candidate.name.is_empty() {
4433        package.name = candidate.name.clone();
4434    }
4435}
4436
4437fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
4438    query_optional_value(
4439        connection,
4440        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
4441        vec![SqlValue::Integer(package_rowid)],
4442        |row| {
4443            Ok(V2PackageMetadata {
4444                rowid: package_rowid,
4445                id: row_string(row, 0)?,
4446                name: row_string(row, 1)?,
4447                moniker: row_opt_string(row, 2)?,
4448                latest_version: row_string(row, 3)?,
4449                package_hash: row_hex_string(row, 4)?,
4450            })
4451        },
4452    )
4453}
4454
4455fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
4456    query_optional_value(
4457        connection,
4458        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
4459        Vec::new(),
4460        |row| row_i64(row, 0),
4461    )
4462    .ok()
4463    .flatten()
4464    .is_some()
4465}
4466
4467/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
4468/// pair matches the installed package — if and only if the match is
4469/// unique. Multiple matches mean the normalization wasn't strong enough to
4470/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
4471/// distinct catalog packages); winget refuses to correlate in that case
4472/// and we do the same.
4473fn lookup_unique_normalized_identity(
4474    connection: &Connection,
4475    norm_name: &str,
4476    norm_publisher: &str,
4477) -> Result<Option<i64>> {
4478    let rows: Vec<i64> = query_rows(
4479        connection,
4480        "SELECT DISTINCT n.package FROM norm_names2 n \
4481         WHERE n.norm_name = ?1 \
4482         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
4483         LIMIT 2",
4484        vec![
4485            SqlValue::Text(norm_name.to_owned()),
4486            SqlValue::Text(norm_publisher.to_owned()),
4487        ],
4488        |row| row_i64(row, 0),
4489    )?;
4490    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
4491}
4492
4493fn v2_identity_tables_present(connection: &Connection) -> bool {
4494    query_optional_value(
4495        connection,
4496        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
4497        Vec::new(),
4498        |row| row_i64(row, 0),
4499    )
4500    .ok()
4501    .flatten()
4502    .is_some()
4503}
4504
4505/// Translates an ARP DisplayVersion to the catalog Version it maps to.
4506/// For packages like the .NET SDK or Python the ARP version is an MSI build
4507/// number (e.g. `10.1.826.23019`) unrelated to the public version
4508/// (`10.0.108`). winget records the ARP range per catalog version in
4509/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
4510/// contains the installed version.
4511fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4512    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4513        return None;
4514    }
4515    for entry in entries {
4516        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
4517            continue;
4518        };
4519        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
4520        {
4521            return Some(entry.version.clone());
4522        }
4523    }
4524    None
4525}
4526
4527/// Returns `< latest>` when ARP range metadata proves the installed version is
4528/// below the newest catalog version, but the raw DisplayVersion is too coarse
4529/// to map into a concrete aMiV/aMaV bucket. Snagit 2025 reports `25.4.1` while
4530/// the catalog's latest ARP build is `25.4.1.10325`; winget renders that as
4531/// installed `< 25.4.1` with available `25.4.1`.
4532fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4533    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4534        return None;
4535    }
4536
4537    let latest = entries
4538        .iter()
4539        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4540        .max_by(|a, b| compare_version(&a.version, &b.version))?;
4541    if compare_version(arp_version, &latest.version) != Ordering::Equal {
4542        return None;
4543    }
4544    let latest_min = latest.arp_min_version.as_deref()?;
4545    if compare_version(arp_version, latest_min) != Ordering::Less {
4546        return None;
4547    }
4548
4549    let previous_max = entries
4550        .iter()
4551        .filter(|entry| compare_version(&entry.version, &latest.version) == Ordering::Less)
4552        .filter_map(|entry| entry.arp_max_version.as_deref())
4553        .max_by(|left, right| compare_version(left, right));
4554    if previous_max.is_some_and(|max| compare_version(arp_version, max) != Ordering::Greater) {
4555        return None;
4556    }
4557
4558    Some(format!("< {}", latest.version))
4559}
4560
4561/// Returns `> latest` when ARP range metadata proves the installed package
4562/// version is newer than the newest catalog version that has ARP bounds. This
4563/// happens for inbox MSIX frameworks that Windows services beyond winget's
4564/// latest user-facing catalog mapping.
4565fn greater_than_latest_arp_anchored_version(
4566    entries: &[PackageVersionDataEntry],
4567    arp_version: &str,
4568    catalog_latest_version: &str,
4569) -> Option<String> {
4570    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4571        return None;
4572    }
4573
4574    let latest = entries
4575        .iter()
4576        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4577        .max_by(|a, b| compare_version(&a.version, &b.version))?;
4578    let latest_max = latest.arp_max_version.as_deref()?;
4579    if !latest.version.eq_ignore_ascii_case(catalog_latest_version) {
4580        return None;
4581    }
4582    if !has_different_leading_version_number(latest_max, &latest.version) {
4583        return None;
4584    }
4585    if compare_version(arp_version, latest_max) != Ordering::Greater {
4586        return None;
4587    }
4588
4589    Some(format!("> {}", latest.version))
4590}
4591
4592fn has_different_leading_version_number(left: &str, right: &str) -> bool {
4593    let Some(left) = leading_version_number(left) else {
4594        return false;
4595    };
4596    let Some(right) = leading_version_number(right) else {
4597        return false;
4598    };
4599    !left.eq_ignore_ascii_case(right)
4600}
4601
4602fn leading_version_number(value: &str) -> Option<&str> {
4603    let trimmed = value.trim_start();
4604    let length = trimmed
4605        .chars()
4606        .take_while(|ch| ch.is_ascii_digit())
4607        .map(char::len_utf8)
4608        .sum();
4609    (length > 0).then_some(&trimmed[..length])
4610}
4611
4612/// Returns the latest catalog Version that carries ARP-range metadata
4613/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
4614/// publish "internal" version rows whose `v` is an MSI build number
4615/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
4616/// upgrade targets — when the installed side was matched through an ARP
4617/// range, the available side must be a peer that also exposes ARP bounds so
4618/// the two versions compare on the same scale. Returns `None` when no entry
4619/// has bounds; the caller should fall back to `packages.latest_version`.
4620fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
4621    entries
4622        .iter()
4623        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4624        .max_by(|a, b| compare_version(&a.version, &b.version))
4625        .map(|entry| entry.version.clone())
4626}
4627
4628#[cfg(windows)]
4629fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4630    let mut packages = Vec::new();
4631    let mut seen = BTreeSet::new();
4632
4633    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
4634    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
4635
4636    // MSI registers UpgradeCode → ProductCode mappings under
4637    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
4638    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
4639    // entries don't expose `UpgradeCode` directly, so winget reads it from
4640    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
4641    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
4642    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
4643    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
4644
4645    if machine {
4646        collect_uninstall_view(
4647            &mut packages,
4648            &mut seen,
4649            &upgrade_codes,
4650            RegKey::predef(HKEY_LOCAL_MACHINE),
4651            "Machine",
4652            "X64",
4653            KEY_READ | KEY_WOW64_64KEY,
4654        )?;
4655        collect_uninstall_view(
4656            &mut packages,
4657            &mut seen,
4658            &upgrade_codes,
4659            RegKey::predef(HKEY_LOCAL_MACHINE),
4660            "Machine",
4661            "X86",
4662            KEY_READ | KEY_WOW64_32KEY,
4663        )?;
4664        collect_appmodel_packages(
4665            &mut packages,
4666            &mut seen,
4667            RegKey::predef(HKEY_LOCAL_MACHINE),
4668            "Machine",
4669            KEY_READ | KEY_WOW64_64KEY,
4670        )?;
4671    }
4672
4673    if user {
4674        collect_uninstall_view(
4675            &mut packages,
4676            &mut seen,
4677            &upgrade_codes,
4678            RegKey::predef(HKEY_CURRENT_USER),
4679            "User",
4680            "X64",
4681            KEY_READ,
4682        )?;
4683        collect_appmodel_packages(
4684            &mut packages,
4685            &mut seen,
4686            RegKey::predef(HKEY_CURRENT_USER),
4687            "User",
4688            KEY_READ,
4689        )?;
4690    }
4691
4692    Ok(packages)
4693}
4694
4695/// Builds a `ProductCode → UpgradeCode` map (both in standard
4696/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
4697/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
4698/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
4699/// flipped ProductCodes registered under that UpgradeCode.
4700#[cfg(windows)]
4701fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4702    let mut map = HashMap::new();
4703    if include_machine {
4704        collect_msi_upgrade_codes_from(
4705            RegKey::predef(HKEY_LOCAL_MACHINE),
4706            r"SOFTWARE\Classes\Installer\UpgradeCodes",
4707            KEY_READ | KEY_WOW64_64KEY,
4708            &mut map,
4709        );
4710    }
4711    if include_user {
4712        collect_msi_upgrade_codes_from(
4713            RegKey::predef(HKEY_CURRENT_USER),
4714            r"Software\Microsoft\Installer\UpgradeCodes",
4715            KEY_READ,
4716            &mut map,
4717        );
4718    }
4719    map
4720}
4721
4722#[cfg(windows)]
4723fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4724    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4725        return;
4726    };
4727    for key_name in upgrade_codes.enum_keys().flatten() {
4728        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4729            continue;
4730        };
4731        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4732            continue;
4733        };
4734        for (value_name, _) in subkey.enum_values().flatten() {
4735            if let Some(product_code) = unflip_packed_guid(&value_name) {
4736                // ARP-side keys are likely to win the latest insert; first one
4737                // is fine — we just need *any* mapping. Use `entry` so the
4738                // first writer per ProductCode wins to stay deterministic.
4739                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4740            }
4741        }
4742    }
4743}
4744
4745/// Converts the 32-character "packed GUID" used inside the MSI Installer
4746/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
4747/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
4748/// then eight 2-char byte pairs) of the GUID's hex representation.
4749#[cfg(windows)]
4750fn unflip_packed_guid(packed: &str) -> Option<String> {
4751    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4752        return None;
4753    }
4754    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4755    let bytes = packed.as_bytes();
4756    let mut reversed = String::with_capacity(32);
4757    let mut offset = 0;
4758    for size in CHUNKS {
4759        let chunk = &bytes[offset..offset + size];
4760        for &c in chunk.iter().rev() {
4761            reversed.push(c as char);
4762        }
4763        offset += size;
4764    }
4765    let r = reversed.to_ascii_lowercase();
4766    Some(format!(
4767        "{{{}-{}-{}-{}-{}}}",
4768        &r[0..8],
4769        &r[8..12],
4770        &r[12..16],
4771        &r[16..20],
4772        &r[20..32]
4773    ))
4774}
4775
4776#[cfg(not(windows))]
4777fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4778    Ok(Vec::new())
4779}
4780
4781#[cfg(windows)]
4782fn collect_uninstall_view(
4783    packages: &mut Vec<InstalledPackage>,
4784    seen: &mut BTreeSet<String>,
4785    upgrade_code_map: &HashMap<String, String>,
4786    root: RegKey,
4787    scope: &str,
4788    arch: &str,
4789    flags: u32,
4790) -> Result<()> {
4791    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4792
4793    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4794        Ok(key) => key,
4795        Err(_) => return Ok(()),
4796    };
4797
4798    for key_name in uninstall.enum_keys().flatten() {
4799        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4800            Ok(key) => key,
4801            Err(_) => continue,
4802        };
4803        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4804        {
4805            continue;
4806        }
4807
4808        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4809            continue;
4810        };
4811
4812        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4813        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4814        let publisher = read_reg_string(&subkey, "Publisher");
4815        let install_location = read_reg_string(&subkey, "InstallLocation");
4816        let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier");
4817        let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier");
4818        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4819            .into_iter()
4820            .collect::<Vec<_>>();
4821        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4822        if !product_codes.iter().any(|code| code.eq_ignore_ascii_case(&key_name)) {
4823            product_codes.push(key_name.to_ascii_lowercase());
4824        }
4825        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4826        if upgrade_codes.is_empty() {
4827            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
4828            // Installer registry hive so identity correlation can fall back to
4829            // upgradecodes2 when productcodes2 picks the wrong sibling.
4830            for code in &product_codes {
4831                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4832                    upgrade_codes.push(uc.clone());
4833                    break;
4834                }
4835            }
4836        }
4837        let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4838            .as_deref()
4839            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4840        {
4841            // Honor the WinGet ARP signal so uninstall flows through
4842            // uninstall_portable (clean dir + registry removal) instead of
4843            // delegating the WinGet portable UninstallString to a winget
4844            // subprocess that only fully cleans up entries winget itself
4845            // installed.
4846            Some("portable".to_owned())
4847        } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4848            Some("msi".to_owned())
4849        } else if key_name.starts_with("MSIX\\") {
4850            Some("msix".to_owned())
4851        } else {
4852            Some("exe".to_owned())
4853        };
4854
4855        let dedupe_key = format!(
4856            "{}|{}|{}|{}",
4857            local_id,
4858            name.to_ascii_lowercase(),
4859            installed_version.to_ascii_lowercase(),
4860            publisher.clone().unwrap_or_default().to_ascii_lowercase()
4861        );
4862        if !seen.insert(dedupe_key) {
4863            continue;
4864        }
4865
4866        packages.push(InstalledPackage {
4867            name,
4868            local_id,
4869            installed_version,
4870            publisher,
4871            scope: Some(scope.to_owned()),
4872            installer_category,
4873            install_location,
4874            winget_package_identifier,
4875            winget_source_identifier,
4876            package_family_names,
4877            product_codes,
4878            upgrade_codes,
4879            correlated: None,
4880            installed_version_canonical: false,
4881            correlated_requires_explicit_upgrade: false,
4882            correlated_lacks_compatible_installer: false,
4883        });
4884    }
4885
4886    Ok(())
4887}
4888
4889#[cfg(windows)]
4890fn collect_appmodel_packages(
4891    packages: &mut Vec<InstalledPackage>,
4892    seen: &mut BTreeSet<String>,
4893    root: RegKey,
4894    scope: &str,
4895    flags: u32,
4896) -> Result<()> {
4897    const APPMODEL_PACKAGES_PATH: &str =
4898        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4899
4900    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4901        Ok(key) => key,
4902        Err(_) => return Ok(()),
4903    };
4904
4905    for key_name in appmodel.enum_keys().flatten() {
4906        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4907            Ok(key) => key,
4908            Err(_) => continue,
4909        };
4910
4911        let install_location = read_reg_string(&subkey, "PackageRootFolder");
4912        if install_location.as_deref().is_some_and(is_windows_system_path) {
4913            continue;
4914        }
4915
4916        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4917            continue;
4918        };
4919        if is_msix_split_resource_package(&metadata.resource_id) {
4920            continue;
4921        }
4922
4923        let Some(name) = read_reg_string(&subkey, "DisplayName")
4924            .or_else(|| read_appmodel_display_name(&subkey))
4925            .filter(|value| !value.is_empty())
4926        else {
4927            continue;
4928        };
4929
4930        let local_id = format!(r"MSIX\{key_name}");
4931        let dedupe_key = format!(
4932            "{}|{}|{}",
4933            local_id,
4934            name.to_ascii_lowercase(),
4935            metadata.version.to_ascii_lowercase()
4936        );
4937        if !seen.insert(dedupe_key) {
4938            continue;
4939        }
4940
4941        packages.push(InstalledPackage {
4942            name,
4943            local_id,
4944            installed_version: metadata.version,
4945            publisher: None,
4946            scope: Some(scope.to_owned()),
4947            installer_category: Some("msix".to_owned()),
4948            install_location,
4949            winget_package_identifier: None,
4950            winget_source_identifier: None,
4951            package_family_names: vec![metadata.family_name],
4952            product_codes: Vec::new(),
4953            upgrade_codes: Vec::new(),
4954            correlated: None,
4955            installed_version_canonical: false,
4956            correlated_requires_explicit_upgrade: false,
4957            correlated_lacks_compatible_installer: false,
4958        });
4959    }
4960
4961    Ok(())
4962}
4963
4964#[cfg(windows)]
4965struct ParsedMsixPackageFullName {
4966    version: String,
4967    resource_id: String,
4968    family_name: String,
4969}
4970
4971#[cfg(windows)]
4972fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4973    let segments = value.split('_').collect::<Vec<_>>();
4974    if segments.len() < 5 {
4975        return None;
4976    }
4977
4978    let name = segments[..segments.len() - 4].join("_");
4979    if name.is_empty() {
4980        return None;
4981    }
4982
4983    let version = segments[segments.len() - 4].trim();
4984    let resource_id = segments[segments.len() - 2].trim();
4985    let publisher_id = segments[segments.len() - 1].trim();
4986    if version.is_empty() || publisher_id.is_empty() {
4987        return None;
4988    }
4989
4990    let family_name = if resource_id.is_empty() {
4991        format!("{name}_{publisher_id}")
4992    } else {
4993        format!("{name}_{resource_id}_{publisher_id}")
4994    };
4995
4996    Some(ParsedMsixPackageFullName {
4997        version: version.to_owned(),
4998        resource_id: resource_id.to_owned(),
4999        family_name,
5000    })
5001}
5002
5003#[cfg(windows)]
5004fn read_appmodel_display_name(package_key: &RegKey) -> Option<String> {
5005    let app_key = package_key.open_subkey(r"App\Capabilities").ok()?;
5006    read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName"))
5007}
5008
5009#[cfg(windows)]
5010fn is_msix_split_resource_package(resource_id: &str) -> bool {
5011    resource_id.trim().to_ascii_lowercase().starts_with("split.")
5012}
5013
5014#[cfg(windows)]
5015fn is_windows_system_path(path: &str) -> bool {
5016    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
5017}
5018
5019#[cfg(windows)]
5020fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
5021    key.get_value::<String, _>(value_name)
5022        .ok()
5023        .map(|value| value.trim().to_owned())
5024        .filter(|value| !value.is_empty())
5025}
5026
5027#[cfg(windows)]
5028fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
5029    key.get_value::<u32, _>(value_name).ok()
5030}
5031
5032fn ensure_app_dirs(app_root: &Path) -> Result<()> {
5033    fs::create_dir_all(app_root).context("failed to create app root directory")?;
5034    if uses_packaged_layout(app_root) {
5035        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
5036        let user_sources_path = packaged_user_sources_path(app_root)?;
5037        if let Some(parent) = user_sources_path.parent() {
5038            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
5039        }
5040    } else {
5041        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
5042    }
5043    Ok(())
5044}
5045
5046fn default_app_root() -> Result<PathBuf> {
5047    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
5048        return Ok(PathBuf::from(app_root));
5049    }
5050
5051    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
5052
5053    #[cfg(windows)]
5054    {
5055        if has_current_package_identity() {
5056            return Ok(local_app_data
5057                .join("Packages")
5058                .join(PACKAGED_FAMILY_NAME)
5059                .join("LocalState"));
5060        }
5061        Ok(local_app_data.join("Devolutions").join("Pinget"))
5062    }
5063
5064    #[cfg(not(windows))]
5065    {
5066        Ok(local_app_data.join("pinget"))
5067    }
5068}
5069
5070#[cfg(windows)]
5071fn has_current_package_identity() -> bool {
5072    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
5073    // process has no AppX package identity.
5074    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
5075
5076    let mut length: u32 = 0;
5077    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
5078    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
5079    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
5080    rc != APPMODEL_ERROR_NO_PACKAGE
5081}
5082
5083fn store_path(app_root: &Path) -> PathBuf {
5084    app_root.join("sources.json")
5085}
5086
5087fn user_settings_path(app_root: &Path) -> PathBuf {
5088    if uses_packaged_layout(app_root) {
5089        app_root.join("settings.json")
5090    } else {
5091        app_root.join("user-settings.json")
5092    }
5093}
5094
5095fn admin_settings_path(app_root: &Path) -> PathBuf {
5096    app_root.join("admin-settings.json")
5097}
5098
5099#[cfg(test)]
5100fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
5101    source_state_dir_with_keying(app_root, source, false)
5102}
5103
5104fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5105    if uses_packaged_layout(app_root) {
5106        return app_root
5107            .join(packaged_source_type(source.kind))
5108            .join(sanitize_path_segment(&source.identifier));
5109    }
5110
5111    if identifier_keyed {
5112        return app_root.join("sources").join(sanitize_path_segment(&source.identifier));
5113    }
5114
5115    app_root.join("sources").join(sanitize_path_segment(&source.name))
5116}
5117
5118fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5119    if uses_packaged_layout(app_root) {
5120        return app_root
5121            .join(sanitize_path_segment(&source.identifier))
5122            .join("installed.db");
5123    }
5124
5125    source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db")
5126}
5127
5128#[cfg(test)]
5129fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
5130    source_state_dir(app_root, source).join("index.db")
5131}
5132
5133fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) {
5134    if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) {
5135        source.etag = Some(value.to_owned());
5136    }
5137
5138    if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) {
5139        source.last_modified = Some(value.to_owned());
5140    }
5141}
5142
5143/// Removes the `-wal` and `-shm` sidecars that SQLite/Turso leaves next to a
5144/// database file. Used after replacing `index.db` so a stale write-ahead log
5145/// from the previous database is not replayed against the new one.
5146fn remove_sqlite_sidecars(db_path: &Path) {
5147    for suffix in ["-wal", "-shm"] {
5148        let mut sidecar = db_path.as_os_str().to_owned();
5149        sidecar.push(suffix);
5150        let _ = fs::remove_file(PathBuf::from(sidecar));
5151    }
5152}
5153
5154#[cfg(windows)]
5155const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
5156
5157#[cfg(windows)]
5158fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
5159    let source_wide = source
5160        .as_os_str()
5161        .encode_wide()
5162        .chain(std::iter::once(0))
5163        .collect::<Vec<_>>();
5164    let target_wide = target
5165        .as_os_str()
5166        .encode_wide()
5167        .chain(std::iter::once(0))
5168        .collect::<Vec<_>>();
5169    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
5170    // SAFETY: Both paths are converted to null-terminated UTF-16 buffers that
5171    // live for the duration of the call; MoveFileExW does not retain them.
5172    let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
5173    if ok == 0 {
5174        return Err(std::io::Error::last_os_error());
5175    }
5176    Ok(())
5177}
5178
5179#[cfg(windows)]
5180fn replace_file(source: &Path, target: &Path) -> Result<()> {
5181    let to_err = |error: std::io::Error| -> anyhow::Error {
5182        anyhow::Error::new(error).context(format!(
5183            "failed to replace {} with {}",
5184            target.display(),
5185            source.display()
5186        ))
5187    };
5188
5189    // Fast path: atomic replace. Succeeds whenever nothing holds the target open.
5190    let first = match move_replace_existing(source, target) {
5191        Ok(()) => return Ok(()),
5192        Err(error) => error,
5193    };
5194
5195    // A missing source or absent target is not a transient lock — fail fast
5196    // instead of spinning through the retry/stage-aside path.
5197    if !source.exists() || !target.exists() {
5198        return Err(to_err(first));
5199    }
5200
5201    // MOVEFILE_REPLACE_EXISTING is denied while another handle holds the target
5202    // open. In practice that handle is an on-access AV scan of the freshly
5203    // written file or a concurrent pinget process reading the index; both are
5204    // short-lived, so retry briefly with a small backoff before falling back.
5205    let mut last = first;
5206    for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
5207        std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
5208        match move_replace_existing(source, target) {
5209            Ok(()) => return Ok(()),
5210            Err(error) => last = error,
5211        }
5212    }
5213
5214    // Persistent holder: stage the existing index aside, then move the new file
5215    // into the freed name. Renaming the existing file succeeds even while a
5216    // reader keeps its handle, because SQLite/Turso opens with FILE_SHARE_DELETE.
5217    // The staged copy is only removed once the new file is in place, and restored
5218    // if the swap fails, so a working file is never lost.
5219    let staged = target.with_extension("old.tmp");
5220    if move_replace_existing(target, &staged).is_err() {
5221        return Err(to_err(last));
5222    }
5223    match move_replace_existing(source, target) {
5224        Ok(()) => {
5225            let _ = fs::remove_file(&staged);
5226            Ok(())
5227        }
5228        Err(error) => {
5229            let _ = move_replace_existing(&staged, target);
5230            Err(to_err(error))
5231        }
5232    }
5233}
5234
5235#[cfg(not(windows))]
5236fn replace_file(source: &Path, target: &Path) -> Result<()> {
5237    fs::rename(source, target)
5238        .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
5239}
5240
5241fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
5242    cache_root(app_root)
5243        .join(bucket)
5244        .join(sanitize_path_segment(identifier))
5245}
5246
5247fn load_store(app_root: &Path) -> Result<SourceStore> {
5248    if uses_packaged_layout(app_root)
5249        && let Some(store) = load_packaged_store(app_root)?
5250    {
5251        return Ok(store);
5252    }
5253
5254    let path = store_path(app_root);
5255    if !path.exists() {
5256        let store = SourceStore::default();
5257        save_store(app_root, &store)?;
5258        return Ok(store);
5259    }
5260
5261    let bytes = fs::read(path).context("failed to read source store")?;
5262    serde_json::from_slice(&bytes).context("failed to parse source store")
5263}
5264
5265fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> {
5266    if uses_system_winget_source_commands(app_root) {
5267        return Ok((
5268            EffectiveSourceMode::SystemWingetDirect,
5269            load_system_winget_source_store()?,
5270        ));
5271    }
5272
5273    match source_mode {
5274        SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5275        SourceMode::SystemWingetMirror => {
5276            let store = load_or_refresh_system_winget_mirror_store(app_root, false)?;
5277            Ok((EffectiveSourceMode::SystemWingetMirror, store))
5278        }
5279        SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) {
5280            Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)),
5281            Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5282        },
5283    }
5284}
5285
5286fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf {
5287    app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME)
5288}
5289
5290fn load_system_winget_mirror_store(app_root: &Path) -> Result<Option<SourceStore>> {
5291    let path = system_winget_mirror_store_path(app_root);
5292    if !path.exists() {
5293        return Ok(None);
5294    }
5295
5296    let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?;
5297    serde_json::from_slice(&bytes)
5298        .context("failed to parse system WinGet mirror source store")
5299        .map(Some)
5300}
5301
5302fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5303    write_json(system_winget_mirror_store_path(app_root), store)
5304}
5305
5306fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result<SourceStore> {
5307    if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? {
5308        return Ok(store);
5309    }
5310
5311    refresh_system_winget_mirror_store(app_root)
5312}
5313
5314fn refresh_system_winget_mirror_store(app_root: &Path) -> Result<SourceStore> {
5315    let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default();
5316    let mut exported = load_system_winget_source_store()?;
5317    merge_source_cache_metadata(&mut exported, &prior);
5318    save_system_winget_mirror_store(app_root, &exported)?;
5319    Ok(exported)
5320}
5321
5322fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) {
5323    let prior_by_identifier: HashMap<_, _> = prior
5324        .sources
5325        .iter()
5326        .map(|source| (source.identifier.to_ascii_lowercase(), source))
5327        .collect();
5328
5329    for source in &mut target.sources {
5330        let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else {
5331            continue;
5332        };
5333        source.last_update = previous.last_update;
5334        source.source_version = previous.source_version.clone();
5335        source.etag = previous.etag.clone();
5336        source.last_modified = previous.last_modified.clone();
5337    }
5338}
5339
5340fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5341    if uses_system_winget_source_commands(app_root) {
5342        bail!("System WinGet source settings must be modified through winget.");
5343    }
5344
5345    if uses_packaged_layout(app_root) {
5346        return save_packaged_store(app_root, store);
5347    }
5348
5349    write_json(store_path(app_root), store)
5350}
5351
5352fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
5353    let user_sources_path = packaged_user_sources_path(app_root)?;
5354    let metadata_path = app_root.join("sources_metadata");
5355    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
5356    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
5357    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
5358}
5359
5360fn load_packaged_store_from_streams(
5361    user_sources_yaml: Option<&str>,
5362    metadata_yaml: Option<&str>,
5363) -> Result<Option<SourceStore>> {
5364    if is_secure_settings_stub(user_sources_yaml) {
5365        return load_system_winget_source_store().map(Some);
5366    }
5367
5368    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
5369}
5370
5371fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5372    let user_sources_path = packaged_user_sources_path(app_root)?;
5373    if let Some(parent) = user_sources_path.parent() {
5374        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
5375    }
5376
5377    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
5378        .context("failed to write packaged user_sources stream")?;
5379    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
5380        .context("failed to write packaged source metadata")?;
5381    Ok(())
5382}
5383
5384fn uses_system_winget_source_commands(app_root: &Path) -> bool {
5385    if !uses_packaged_layout(app_root) {
5386        return false;
5387    }
5388
5389    #[cfg(windows)]
5390    {
5391        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
5392            return false;
5393        };
5394
5395        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
5396        is_secure_settings_stub(user_sources_yaml.as_deref())
5397    }
5398
5399    #[cfg(not(windows))]
5400    {
5401        false
5402    }
5403}
5404
5405fn is_secure_settings_stub(value: Option<&str>) -> bool {
5406    value
5407        .map(str::trim_start)
5408        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
5409}
5410
5411fn load_system_winget_source_store() -> Result<SourceStore> {
5412    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
5413    Ok(SourceStore {
5414        sources: parse_system_winget_source_export(&result.stdout)?,
5415    })
5416}
5417
5418fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
5419    run_checked_winget_source_command(
5420        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
5421        &format!("add WinGet source '{name}'"),
5422    )?;
5423    Ok(())
5424}
5425
5426fn system_winget_remove_source(name: &str) -> Result<()> {
5427    run_checked_winget_source_command(
5428        vec![
5429            "source".to_owned(),
5430            "remove".to_owned(),
5431            "--name".to_owned(),
5432            name.to_owned(),
5433            "--disable-interactivity".to_owned(),
5434        ],
5435        &format!("remove WinGet source '{name}'"),
5436    )?;
5437    Ok(())
5438}
5439
5440fn system_winget_reset_source(name: &str) -> Result<()> {
5441    run_checked_winget_source_command(
5442        vec![
5443            "source".to_owned(),
5444            "reset".to_owned(),
5445            "--name".to_owned(),
5446            name.to_owned(),
5447            "--force".to_owned(),
5448            "--disable-interactivity".to_owned(),
5449        ],
5450        &format!("reset WinGet source '{name}'"),
5451    )?;
5452    Ok(())
5453}
5454
5455fn system_winget_reset_sources() -> Result<()> {
5456    run_checked_winget_source_command(
5457        vec![
5458            "source".to_owned(),
5459            "reset".to_owned(),
5460            "--force".to_owned(),
5461            "--disable-interactivity".to_owned(),
5462        ],
5463        "reset WinGet sources",
5464    )?;
5465    Ok(())
5466}
5467
5468fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
5469    let mut args = vec!["source".to_owned(), "update".to_owned()];
5470    if let Some(name) = name {
5471        args.push("--name".to_owned());
5472        args.push(name.to_owned());
5473    }
5474
5475    args.push("--disable-interactivity".to_owned());
5476    let result = run_checked_winget_source_command(
5477        args,
5478        if name.is_some() {
5479            "update WinGet source"
5480        } else {
5481            "update WinGet sources"
5482        },
5483    )?;
5484    let detail = result.stdout.trim();
5485    Ok(if detail.is_empty() {
5486        "Done".to_owned()
5487    } else {
5488        detail.to_owned()
5489    })
5490}
5491
5492fn build_winget_source_export_arguments() -> Vec<String> {
5493    vec![
5494        "source".to_owned(),
5495        "export".to_owned(),
5496        "--disable-interactivity".to_owned(),
5497    ]
5498}
5499
5500fn build_winget_source_add_arguments(
5501    name: &str,
5502    arg: &str,
5503    kind: SourceKind,
5504    trust_level: &str,
5505    explicit: bool,
5506) -> Vec<String> {
5507    let mut args = vec![
5508        "source".to_owned(),
5509        "add".to_owned(),
5510        "--name".to_owned(),
5511        name.to_owned(),
5512        "--arg".to_owned(),
5513        arg.to_owned(),
5514        "--type".to_owned(),
5515        packaged_source_type(kind).to_owned(),
5516        "--disable-interactivity".to_owned(),
5517    ];
5518
5519    if !trust_level.trim().is_empty() {
5520        args.push("--trust-level".to_owned());
5521        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
5522            "trusted".to_owned()
5523        } else {
5524            "none".to_owned()
5525        });
5526    }
5527
5528    if explicit {
5529        args.push("--explicit".to_owned());
5530    }
5531
5532    args
5533}
5534
5535#[derive(Debug)]
5536struct WingetSourceCommandResult {
5537    exit_code: i32,
5538    stdout: String,
5539    stderr: String,
5540}
5541
5542type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
5543
5544static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
5545    RwLock::new(run_winget_source_command_process);
5546
5547fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
5548    let result = run_winget_source_command(&args)?;
5549    if result.exit_code == 0 {
5550        return Ok(result);
5551    }
5552
5553    let detail = [result.stderr.trim(), result.stdout.trim()]
5554        .into_iter()
5555        .filter(|value| !value.is_empty())
5556        .collect::<Vec<_>>()
5557        .join("\n");
5558    if detail.is_empty() {
5559        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
5560    }
5561
5562    bail!(
5563        "Failed to {action}; winget exited with code {}:\n{}",
5564        result.exit_code,
5565        detail
5566    );
5567}
5568
5569fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
5570    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
5571        .read()
5572        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
5573    runner(args)
5574}
5575
5576fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
5577    use std::process::Command;
5578
5579    let output = Command::new("winget")
5580        .args(args)
5581        .output()
5582        .context("failed to run winget source command")?;
5583
5584    Ok(WingetSourceCommandResult {
5585        exit_code: output.status.code().unwrap_or(-1),
5586        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5587        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5588    })
5589}
5590
5591fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
5592    let trimmed = output.trim();
5593    if trimmed.is_empty() {
5594        return Ok(Vec::new());
5595    }
5596
5597    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
5598        && let Some(sources) = parse_system_winget_source_export_value(&value)?
5599    {
5600        return Ok(sources);
5601    }
5602
5603    let mut sources = Vec::new();
5604    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
5605        if line.is_empty() {
5606            continue;
5607        }
5608
5609        let value: JsonValue = serde_json::from_str(line)
5610            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
5611        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
5612            bail!("WinGet source export returned a non-source line: {line}");
5613        };
5614        sources.append(&mut parsed);
5615    }
5616
5617    Ok(sources)
5618}
5619
5620fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
5621    match value {
5622        JsonValue::Array(items) => items
5623            .iter()
5624            .map(parse_system_winget_source_record)
5625            .collect::<Result<Vec<_>>>()
5626            .map(Some),
5627        JsonValue::Object(object) => {
5628            if let Some(JsonValue::Array(items)) = object.get("Sources") {
5629                items
5630                    .iter()
5631                    .map(parse_system_winget_source_record)
5632                    .collect::<Result<Vec<_>>>()
5633                    .map(Some)
5634            } else if object.contains_key("Name") {
5635                Ok(Some(vec![parse_system_winget_source_record(value)?]))
5636            } else {
5637                Ok(None)
5638            }
5639        }
5640        _ => Ok(None),
5641    }
5642}
5643
5644fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
5645    let object = value
5646        .as_object()
5647        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
5648    let name = required_json_string(object, "Name")?;
5649    let source_type = required_json_string(object, "Type")?;
5650    let arg = required_json_string(object, "Arg")?;
5651    let kind = parse_system_winget_source_kind(&source_type)?;
5652    let identifier = optional_json_string(object, "Identifier")
5653        .or_else(|| optional_json_string(object, "Data"))
5654        .unwrap_or_else(|| name.clone());
5655
5656    Ok(SourceRecord {
5657        name,
5658        kind,
5659        arg,
5660        identifier,
5661        trust_level: parse_system_winget_trust_level(object),
5662        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
5663        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
5664        last_update: None,
5665        source_version: None,
5666        etag: None,
5667        last_modified: None,
5668    })
5669}
5670
5671fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
5672    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
5673}
5674
5675fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
5676    match object.get(key)? {
5677        JsonValue::String(value) => Some(value.clone()),
5678        JsonValue::Number(value) => Some(value.to_string()),
5679        _ => None,
5680    }
5681}
5682
5683fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
5684    match object.get(key)? {
5685        JsonValue::Bool(value) => Some(*value),
5686        JsonValue::String(value) => value.parse().ok(),
5687        _ => None,
5688    }
5689}
5690
5691fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
5692    match object.get(key)? {
5693        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
5694        JsonValue::String(value) => value.parse().ok(),
5695        _ => None,
5696    }
5697}
5698
5699fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
5700    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5701        Ok(SourceKind::PreIndexed)
5702    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5703        Ok(SourceKind::Rest)
5704    } else {
5705        bail!("Unsupported WinGet source type '{value}'.")
5706    }
5707}
5708
5709fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
5710    let Some(value) = object.get("TrustLevel") else {
5711        return "None".to_owned();
5712    };
5713
5714    match value {
5715        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
5716        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
5717        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
5718        _ => "None".to_owned(),
5719    }
5720}
5721
5722fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
5723    value
5724        .as_str()
5725        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
5726}
5727
5728fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
5729    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
5730        return None;
5731    }
5732
5733    let mut sources = SourceStore::default().sources;
5734    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
5735        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5736            continue;
5737        };
5738
5739        if yaml_bool(entry.get("IsTombstone")) {
5740            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
5741            continue;
5742        }
5743
5744        let Some(mapped) = map_packaged_source_entry(&entry) else {
5745            continue;
5746        };
5747
5748        if let Some(existing) = sources
5749            .iter_mut()
5750            .find(|source| source.name.eq_ignore_ascii_case(&name))
5751        {
5752            *existing = mapped;
5753        } else {
5754            sources.push(mapped);
5755        }
5756    }
5757
5758    for entry in parse_packaged_yaml_entries(metadata_yaml) {
5759        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5760            continue;
5761        };
5762
5763        let Some(source) = sources
5764            .iter_mut()
5765            .find(|source| source.name.eq_ignore_ascii_case(&name))
5766        else {
5767            continue;
5768        };
5769
5770        if let Some(last_update) = entry
5771            .get("LastUpdate")
5772            .and_then(|value| value.as_deref())
5773            .and_then(parse_packaged_datetime)
5774        {
5775            source.last_update = Some(last_update);
5776        }
5777
5778        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
5779            && !source_version.is_empty()
5780        {
5781            source.source_version = Some(source_version);
5782        }
5783
5784        if let Some(etag) = entry.get("ETag").and_then(|value| value.clone())
5785            && !etag.is_empty()
5786        {
5787            source.etag = Some(etag);
5788        }
5789
5790        if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone())
5791            && !last_modified.is_empty()
5792        {
5793            source.last_modified = Some(last_modified);
5794        }
5795    }
5796
5797    Some(SourceStore { sources })
5798}
5799
5800fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
5801    let Some(yaml) = yaml else {
5802        return Vec::new();
5803    };
5804
5805    let mut entries = Vec::new();
5806    let mut current: Option<BTreeMap<String, Option<String>>> = None;
5807    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
5808        let line = raw_line.trim_end();
5809        let trimmed = line.trim();
5810        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
5811            continue;
5812        }
5813
5814        if let Some(remainder) = trimmed.strip_prefix("- ") {
5815            if let Some(entry) = current.take() {
5816                entries.push(entry);
5817            }
5818
5819            let mut entry = BTreeMap::new();
5820            add_packaged_yaml_key_value(&mut entry, remainder);
5821            current = Some(entry);
5822            continue;
5823        }
5824
5825        if let Some(entry) = current.as_mut() {
5826            add_packaged_yaml_key_value(entry, trimmed);
5827        }
5828    }
5829
5830    if let Some(entry) = current {
5831        entries.push(entry);
5832    }
5833
5834    entries
5835}
5836
5837fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
5838    let Some((key, value)) = line.split_once(':') else {
5839        return;
5840    };
5841
5842    entry.insert(
5843        key.trim().to_owned(),
5844        Some(unquote_yaml_scalar(value.trim()).to_owned()),
5845    );
5846}
5847
5848fn unquote_yaml_scalar(value: &str) -> &str {
5849    if value.len() >= 2 {
5850        let bytes = value.as_bytes();
5851        let first = bytes[0] as char;
5852        let last = bytes[value.len() - 1] as char;
5853        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
5854            return &value[1..value.len() - 1];
5855        }
5856    }
5857
5858    value
5859}
5860
5861fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
5862    let name = entry.get("Name").and_then(|value| value.clone())?;
5863    let source_type = entry.get("Type").and_then(|value| value.clone())?;
5864    let arg = entry.get("Arg").and_then(|value| value.clone())?;
5865    let kind = parse_packaged_source_kind(&source_type)?;
5866
5867    Some(SourceRecord {
5868        name: name.clone(),
5869        kind,
5870        arg,
5871        identifier: entry
5872            .get("Identifier")
5873            .and_then(|value| value.clone())
5874            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
5875            .unwrap_or(name),
5876        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
5877        explicit: yaml_bool(entry.get("Explicit")),
5878        priority: entry
5879            .get("Priority")
5880            .and_then(|value| value.as_deref())
5881            .and_then(|value| value.parse::<i32>().ok())
5882            .unwrap_or_default(),
5883        last_update: None,
5884        source_version: None,
5885        etag: None,
5886        last_modified: None,
5887    })
5888}
5889
5890fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
5891    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5892        Some(SourceKind::PreIndexed)
5893    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5894        Some(SourceKind::Rest)
5895    } else {
5896        None
5897    }
5898}
5899
5900fn parse_packaged_trust_level(value: Option<&str>) -> String {
5901    match value {
5902        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5903        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5904        _ => "None".to_owned(),
5905    }
5906}
5907
5908fn yaml_bool(value: Option<&Option<String>>) -> bool {
5909    value
5910        .and_then(|raw| raw.as_deref())
5911        .and_then(|raw| raw.parse::<bool>().ok())
5912        .unwrap_or(false)
5913}
5914
5915fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5916    if let Ok(unix_seconds) = value.parse::<i64>() {
5917        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5918    }
5919
5920    DateTime::parse_from_rfc3339(value)
5921        .ok()
5922        .map(|value| value.with_timezone(&Utc))
5923}
5924
5925fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5926    let mut lines = vec!["Sources:".to_owned()];
5927    for source in &store.sources {
5928        lines.push(format!("  - Name: {}", source.name));
5929        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
5930        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
5931        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
5932        lines.push(format!(
5933            "    TrustLevel: {}",
5934            if source.trust_level.eq_ignore_ascii_case("Trusted") {
5935                1
5936            } else {
5937                0
5938            }
5939        ));
5940        lines.push(format!("    Explicit: {}", source.explicit));
5941        lines.push(format!("    Priority: {}", source.priority));
5942        lines.push("    IsTombstone: false".to_owned());
5943    }
5944    lines.push(String::new());
5945    lines.join("\n")
5946}
5947
5948fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5949    let mut lines = vec!["Sources:".to_owned()];
5950    for source in &store.sources {
5951        if source.last_update.is_none()
5952            && source.source_version.is_none()
5953            && source.etag.is_none()
5954            && source.last_modified.is_none()
5955        {
5956            continue;
5957        }
5958
5959        lines.push(format!("  - Name: {}", source.name));
5960        if let Some(last_update) = source.last_update {
5961            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
5962        }
5963        if let Some(source_version) = &source.source_version {
5964            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
5965        }
5966        if let Some(etag) = &source.etag {
5967            lines.push(format!("    ETag: {}", yaml_scalar(etag)));
5968        }
5969        if let Some(last_modified) = &source.last_modified {
5970            lines.push(format!("    LastModified: {}", yaml_scalar(last_modified)));
5971        }
5972    }
5973    lines.push(String::new());
5974    lines.join("\n")
5975}
5976
5977fn yaml_scalar(value: &str) -> String {
5978    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
5979        format!("\"{}\"", value.replace('"', "\\\""))
5980    } else {
5981        value.to_owned()
5982    }
5983}
5984
5985fn uses_packaged_layout(app_root: &Path) -> bool {
5986    #[cfg(windows)]
5987    {
5988        try_get_packaged_local_state_root(app_root).is_some()
5989    }
5990
5991    #[cfg(not(windows))]
5992    {
5993        let _ = app_root;
5994        false
5995    }
5996}
5997
5998#[cfg(windows)]
5999fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
6000    let local_state = app_root.file_name()?.to_string_lossy();
6001    if !local_state.eq_ignore_ascii_case("LocalState") {
6002        return None;
6003    }
6004
6005    let family = app_root.parent()?.file_name()?.to_string_lossy();
6006    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
6007        return None;
6008    }
6009
6010    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
6011    if !packages.eq_ignore_ascii_case("Packages") {
6012        return None;
6013    }
6014
6015    Some(app_root.to_path_buf())
6016}
6017
6018#[cfg(windows)]
6019fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
6020    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
6021    Ok(packaged_secure_settings_root()?.join("user_sources"))
6022}
6023
6024#[cfg(not(windows))]
6025fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
6026    bail!("packaged WinGet source streams are only available on Windows")
6027}
6028
6029#[cfg(windows)]
6030fn packaged_secure_settings_root() -> Result<PathBuf> {
6031    let program_data = std::env::var_os("PROGRAMDATA")
6032        .map(PathBuf::from)
6033        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
6034    Ok(program_data
6035        .join("Microsoft")
6036        .join("WinGet")
6037        .join(current_user_sid()?)
6038        .join("settings")
6039        .join("pkg")
6040        .join(PACKAGED_NAME))
6041}
6042
6043#[cfg(windows)]
6044#[allow(clippy::multiple_unsafe_ops_per_block)]
6045fn current_user_sid() -> Result<String> {
6046    fn close_token(token: HANDLE) {
6047        if !token.is_null() {
6048            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
6049            unsafe {
6050                CloseHandle(token);
6051            }
6052        }
6053    }
6054
6055    let mut token: HANDLE = std::ptr::null_mut();
6056    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
6057    let current_process = unsafe { GetCurrentProcess() };
6058    // SAFETY: token points to valid storage for the process token handle on success.
6059    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
6060        bail!("failed to open current process token")
6061    }
6062
6063    let mut length = 0;
6064    // SAFETY: The null buffer call asks Windows for the required buffer length.
6065    unsafe {
6066        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
6067    }
6068
6069    let element_size = size_of::<TOKEN_USER>();
6070    let element_count = (length as usize).div_ceil(element_size);
6071    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
6072    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
6073    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
6074        close_token(token);
6075        bail!("failed to get current user token information")
6076    }
6077
6078    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
6079    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
6080    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
6081    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
6082    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
6083        close_token(token);
6084        bail!("failed to convert current user SID")
6085    }
6086
6087    let mut len = 0usize;
6088    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
6089    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
6090        len += 1;
6091    }
6092
6093    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
6094    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
6095    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
6096    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
6097    unsafe {
6098        LocalFree(sid_ptr.cast());
6099    }
6100    close_token(token);
6101    Ok(sid)
6102}
6103
6104fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
6105    app_root.join("Microsoft").join("Windows Package Manager")
6106}
6107
6108fn cache_root(app_root: &Path) -> PathBuf {
6109    if uses_packaged_layout(app_root) {
6110        packaged_file_cache_root(app_root)
6111    } else {
6112        default_cache_root_fallback()
6113    }
6114}
6115
6116fn default_cache_root_fallback() -> PathBuf {
6117    std::env::temp_dir().join("cache")
6118}
6119
6120fn packaged_source_type(kind: SourceKind) -> &'static str {
6121    match kind {
6122        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
6123        SourceKind::Rest => "Microsoft.Rest",
6124    }
6125}
6126
6127fn sanitize_path_segment(value: &str) -> String {
6128    value
6129        .chars()
6130        .map(|ch| match ch {
6131            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
6132            _ => ch,
6133        })
6134        .collect()
6135}
6136
6137fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
6138    if !path.exists() {
6139        return Ok(JsonMap::new());
6140    }
6141
6142    let bytes = fs::read(path).context("failed to read JSON file")?;
6143    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
6144    Ok(match value {
6145        JsonValue::Object(object) => object,
6146        _ => JsonMap::new(),
6147    })
6148}
6149
6150fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
6151    write_json(path, value)
6152}
6153
6154fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
6155    if let Some(parent) = path.parent() {
6156        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
6157    }
6158
6159    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
6160    fs::write(path, bytes).context("failed to write JSON file")
6161}
6162
6163fn merge_json_objects(
6164    current: &JsonMap<String, JsonValue>,
6165    update: &JsonMap<String, JsonValue>,
6166) -> JsonMap<String, JsonValue> {
6167    let mut merged = current.clone();
6168    for (key, value) in update {
6169        match (merged.get(key), value) {
6170            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
6171                merged.insert(
6172                    key.clone(),
6173                    JsonValue::Object(merge_json_objects(current_object, update_object)),
6174                );
6175            }
6176            _ => {
6177                merged.insert(key.clone(), value.clone());
6178            }
6179        }
6180    }
6181
6182    merged
6183}
6184
6185fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
6186    match expected {
6187        JsonValue::Object(expected_object) => match current {
6188            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
6189                current_object
6190                    .get(key)
6191                    .is_some_and(|current_child| json_contains(current_child, expected_child))
6192            }),
6193            _ => false,
6194        },
6195        JsonValue::Array(expected_array) => match current {
6196            JsonValue::Array(current_array) => {
6197                current_array.len() == expected_array.len()
6198                    && current_array
6199                        .iter()
6200                        .zip(expected_array.iter())
6201                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
6202            }
6203            _ => false,
6204        },
6205        _ => current == expected,
6206    }
6207}
6208
6209fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
6210    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
6211        "none" | "default" => Ok("None".to_owned()),
6212        "trusted" => Ok("Trusted".to_owned()),
6213        other => bail!("unsupported source trust level: {other}"),
6214    }
6215}
6216
6217fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
6218    SUPPORTED_ADMIN_SETTINGS
6219        .iter()
6220        .copied()
6221        .find(|setting| setting.eq_ignore_ascii_case(name))
6222        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
6223}
6224
6225fn default_source_trust_level() -> String {
6226    "None".to_owned()
6227}
6228
6229fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
6230where
6231    F: FnMut(&SqlRow) -> Result<T>,
6232{
6233    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6234        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6235    let mut result = Vec::new();
6236
6237    while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
6238        result.push(map(&row)?);
6239    }
6240
6241    Ok(result)
6242}
6243
6244fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
6245where
6246    F: FnOnce(&SqlRow) -> Result<T>,
6247{
6248    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6249        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6250
6251    match pollster::block_on(rows.next()).context("failed to read SQL row")? {
6252        Some(row) => Ok(Some(map(&row)?)),
6253        None => Ok(None),
6254    }
6255}
6256
6257fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
6258    pollster::block_on(connection.execute(sql, params_from_iter(params)))
6259        .with_context(|| format!("failed to execute SQL statement: {sql}"))
6260}
6261
6262fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
6263    pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
6264}
6265
6266fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
6267    row.get_value(index)
6268        .with_context(|| format!("failed to read SQL column {index}"))
6269}
6270
6271fn row_string(row: &SqlRow, index: usize) -> Result<String> {
6272    match row_value(row, index)? {
6273        SqlValue::Text(value) => Ok(value),
6274        SqlValue::Integer(value) => Ok(value.to_string()),
6275        SqlValue::Real(value) => Ok(value.to_string()),
6276        SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
6277        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6278    }
6279}
6280
6281fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6282    match row_value(row, index)? {
6283        SqlValue::Null => Ok(None),
6284        SqlValue::Text(value) => Ok(Some(value)),
6285        SqlValue::Integer(value) => Ok(Some(value.to_string())),
6286        SqlValue::Real(value) => Ok(Some(value.to_string())),
6287        SqlValue::Blob(value) => Ok(Some(
6288            String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
6289        )),
6290    }
6291}
6292
6293fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
6294    match row_value(row, index)? {
6295        SqlValue::Integer(value) => Ok(value),
6296        SqlValue::Text(value) => value
6297            .parse::<i64>()
6298            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
6299        SqlValue::Real(value) => f64_to_i64_checked(value, index),
6300        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6301        SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
6302    }
6303}
6304
6305fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
6306    match row_value(row, index)? {
6307        SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
6308        SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
6309        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6310        SqlValue::Integer(value) => Ok(format!("{value:x}")),
6311        SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
6312    }
6313}
6314
6315fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6316    match row_value(row, index)? {
6317        SqlValue::Null => Ok(None),
6318        SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
6319        SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
6320        SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
6321        SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
6322    }
6323}
6324
6325fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
6326    if !value.is_finite() {
6327        bail!("SQL column {index} contained a non-finite floating-point value");
6328    }
6329
6330    if value.fract() != 0.0 {
6331        bail!("SQL column {index} contained a non-integer floating-point value");
6332    }
6333
6334    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
6335        bail!("SQL column {index} contained a floating-point value outside the i64 range");
6336    }
6337
6338    format!("{value:.0}")
6339        .parse::<i64>()
6340        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
6341}
6342
6343fn bytes_to_hex(bytes: &[u8]) -> String {
6344    let mut result = String::with_capacity(bytes.len() * 2);
6345    for byte in bytes {
6346        result.push_str(&format!("{byte:02x}"));
6347    }
6348    result
6349}
6350
6351fn query_v2_matches(
6352    connection: &Connection,
6353    query: &PackageQuery,
6354    semantics: SearchSemantics,
6355) -> Result<(Vec<V2SearchRow>, bool)> {
6356    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
6357    let limit = source_fetch_results(query, semantics);
6358    let sql = format!(
6359        "SELECT rowid, id, name, moniker, latest_version, hash \
6360         FROM packages WHERE {where_clause} LIMIT {}",
6361        limit + 1
6362    );
6363    let mut rows = query_rows(
6364        connection,
6365        &sql,
6366        params.into_iter().map(SqlValue::Text).collect(),
6367        |row| {
6368            Ok(V2SearchRow {
6369                package_rowid: row_i64(row, 0)?,
6370                id: row_string(row, 1)?,
6371                name: row_string(row, 2)?,
6372                moniker: row_opt_string(row, 3)?,
6373                version: row_string(row, 4)?,
6374                package_hash: row_hex_string(row, 5)?,
6375                match_criteria: None,
6376            })
6377        },
6378    )?;
6379    for row in &mut rows {
6380        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
6381    }
6382    let truncated = rows.len() > limit;
6383    rows.truncate(limit);
6384    Ok((rows, truncated))
6385}
6386
6387fn query_v1_matches(
6388    connection: &Connection,
6389    query: &PackageQuery,
6390    semantics: SearchSemantics,
6391) -> Result<(Vec<V1SearchRow>, bool)> {
6392    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
6393    let limit = source_fetch_results(query, semantics);
6394    let sql = format!(
6395        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
6396         FROM manifest \
6397         JOIN ids ON manifest.id = ids.rowid \
6398         JOIN names ON manifest.name = names.rowid \
6399         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
6400         JOIN versions ON manifest.version = versions.rowid \
6401         JOIN channels ON manifest.channel = channels.rowid \
6402         WHERE {where_clause} LIMIT {}",
6403        limit + 1
6404    );
6405    let mut rows = query_rows(
6406        connection,
6407        &sql,
6408        params.into_iter().map(SqlValue::Text).collect(),
6409        |row| {
6410            Ok(V1SearchRow {
6411                manifest_rowid: row_i64(row, 0)?,
6412                package_rowid: row_i64(row, 1)?,
6413                version: row_string(row, 2)?,
6414                channel: row_string(row, 3)?,
6415                id: row_string(row, 4)?,
6416                name: row_string(row, 5)?,
6417                moniker: row_opt_string(row, 6)?,
6418                match_criteria: None,
6419            })
6420        },
6421    )?;
6422    for row in &mut rows {
6423        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
6424    }
6425    let truncated = rows.len() > limit;
6426    rows.truncate(limit);
6427    Ok((rows, truncated))
6428}
6429
6430fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
6431    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
6432
6433    for row in rows {
6434        match grouped.get(&row.package_rowid) {
6435            Some(existing)
6436                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
6437                    != Ordering::Less => {}
6438            _ => {
6439                grouped.insert(row.package_rowid, row);
6440            }
6441        }
6442    }
6443
6444    grouped.into_values().collect()
6445}
6446
6447fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
6448    let has_hash = table_has_column(connection, "manifest", "hash")?;
6449    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
6450    let sql = format!(
6451        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
6452         FROM manifest \
6453         JOIN versions ON manifest.version = versions.rowid \
6454         JOIN channels ON manifest.channel = channels.rowid \
6455         WHERE manifest.id = ?1"
6456    );
6457    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
6458        Ok(V1VersionRow {
6459            version: row_string(row, 0)?,
6460            channel: row_string(row, 1)?,
6461            pathpart_id: row_i64(row, 2)?,
6462            manifest_hash: row_opt_hex_string(row, 3)?,
6463        })
6464    })
6465}
6466
6467fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
6468    let mut parts = Vec::new();
6469    let mut current = Some(pathpart_id);
6470
6471    while let Some(id) = current {
6472        let (parent, pathpart) = query_optional_value(
6473            connection,
6474            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
6475            vec![SqlValue::Integer(id)],
6476            |row| {
6477                Ok((
6478                    row_opt_string(row, 0)?
6479                        .and_then(|value| value.parse::<i64>().ok())
6480                        .filter(|value| *value != 0),
6481                    row_string(row, 1)?,
6482                ))
6483            },
6484        )?
6485        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
6486        parts.push(pathpart);
6487        current = parent;
6488    }
6489
6490    parts.reverse();
6491    Ok(parts.join("/"))
6492}
6493
6494fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
6495    format!(
6496        "packages/{}/{}/versionData.mszyml",
6497        package_id,
6498        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
6499    )
6500}
6501
6502fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
6503    let message = format!("{error:#}").to_ascii_lowercase();
6504    message.contains("no such table") && message.contains("packages")
6505}
6506
6507fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
6508    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
6509        row_string(row, 1)
6510    })? {
6511        if value.eq_ignore_ascii_case(column) {
6512            return Ok(true);
6513        }
6514    }
6515
6516    Ok(false)
6517}
6518
6519fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
6520    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
6521    let id_column = if v2 { "id" } else { "ids.id" };
6522    let name_column = if v2 { "name" } else { "names.name" };
6523    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
6524    let exact_match = query.exact || semantics == SearchSemantics::Single;
6525    let mut params = Vec::new();
6526
6527    if let Some(value) = &query.id {
6528        return (
6529            single_field_condition(id_column, value, exact_match, &mut params),
6530            params,
6531        );
6532    }
6533    if let Some(value) = &query.name {
6534        return (
6535            single_field_condition(name_column, value, exact_match, &mut params),
6536            params,
6537        );
6538    }
6539    if let Some(value) = &query.moniker {
6540        return (
6541            single_field_condition(moniker_column, value, exact_match, &mut params),
6542            params,
6543        );
6544    }
6545    if let Some(value) = &query.tag {
6546        return (
6547            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
6548            params,
6549        );
6550    }
6551    if let Some(value) = &query.command {
6552        return (
6553            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
6554            params,
6555        );
6556    }
6557    if let Some(value) = &query.query {
6558        if exact_match {
6559            let conditions = [
6560                single_field_condition(id_column, value, true, &mut params),
6561                single_field_condition(name_column, value, true, &mut params),
6562                single_field_condition(moniker_column, value, true, &mut params),
6563            ];
6564            return (format!("({})", conditions.join(" OR ")), params);
6565        }
6566
6567        let mut conditions = vec![
6568            single_field_condition(id_column, value, false, &mut params),
6569            single_field_condition(name_column, value, false, &mut params),
6570            single_field_condition(moniker_column, value, false, &mut params),
6571        ];
6572        if semantics == SearchSemantics::Many {
6573            conditions.push(mapped_field_condition(
6574                v2,
6575                "tag",
6576                value,
6577                rowid_column,
6578                false,
6579                &mut params,
6580            ));
6581            conditions.push(mapped_field_condition(
6582                v2,
6583                "command",
6584                value,
6585                rowid_column,
6586                false,
6587                &mut params,
6588            ));
6589        }
6590        return (format!("({})", conditions.join(" OR ")), params);
6591    }
6592
6593    ("1 = 1".to_owned(), Vec::new())
6594}
6595
6596fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
6597    params.push(match_parameter(value, exact));
6598    format!("{column} LIKE ?{}", params.len())
6599}
6600
6601fn mapped_field_condition(
6602    v2: bool,
6603    value_name: &str,
6604    value: &str,
6605    rowid_column: &str,
6606    exact: bool,
6607    params: &mut Vec<String>,
6608) -> String {
6609    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
6610        (
6611            format!("{value_name}s2"),
6612            format!("{value_name}s2_map"),
6613            value_name.to_owned(),
6614            "package".to_owned(),
6615        )
6616    } else {
6617        (
6618            format!("{value_name}s"),
6619            format!("{value_name}s_map"),
6620            value_name.to_owned(),
6621            "manifest".to_owned(),
6622        )
6623    };
6624    params.push(match_parameter(value, exact));
6625    let parameter = params.len();
6626    // Use a non-correlated `rowid IN (...)` subquery rather than a correlated
6627    // `EXISTS (... WHERE map.owner = packages.rowid ...)`. The correlated form
6628    // re-scans the (un-indexed) map table once per candidate package row, which
6629    // is quadratic: on the real winget index it takes ~19s under SQLite and
6630    // effectively hangs under the Turso engine. The `IN` form evaluates the
6631    // owner-id set a single time, collapsing the search back to milliseconds.
6632    format!(
6633        "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
6634         FROM {map_table_name} JOIN {table_name} ON \
6635         {map_table_name}.{value_name} = {table_name}.rowid \
6636         WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
6637    )
6638}
6639
6640fn match_parameter(value: &str, exact: bool) -> String {
6641    if exact { value.to_owned() } else { format!("%{value}%") }
6642}
6643
6644fn build_rest_search_body(
6645    query: &PackageQuery,
6646    info: &RestInformation,
6647    semantics: SearchSemantics,
6648) -> Result<JsonValue> {
6649    let mut root = serde_json::Map::new();
6650    root.insert(
6651        "MaximumResults".to_owned(),
6652        JsonValue::from(source_fetch_results(query, semantics) as u64),
6653    );
6654    let exact_match = query.exact || semantics == SearchSemantics::Single;
6655
6656    if let Some(value) = &query.query {
6657        if semantics == SearchSemantics::Single {
6658            let mut filters = vec![
6659                rest_filter("PackageIdentifier", value, true),
6660                rest_filter("PackageName", value, true),
6661                rest_filter("Moniker", value, true),
6662            ];
6663            append_required_rest_filters(&mut filters, info);
6664            root.insert("Filters".to_owned(), JsonValue::Array(filters));
6665            return Ok(JsonValue::Object(root));
6666        }
6667
6668        root.insert(
6669            "Query".to_owned(),
6670            serde_json::json!({
6671                "KeyWord": value,
6672                "MatchType": if exact_match { "Exact" } else { "Substring" },
6673            }),
6674        );
6675    }
6676
6677    let mut filters = Vec::new();
6678    if let Some(value) = &query.id {
6679        filters.push(rest_filter("PackageIdentifier", value, exact_match));
6680    }
6681    if let Some(value) = &query.name {
6682        filters.push(rest_filter("PackageName", value, exact_match));
6683    }
6684    if let Some(value) = &query.moniker {
6685        filters.push(rest_filter("Moniker", value, exact_match));
6686    }
6687    if let Some(value) = &query.tag {
6688        filters.push(rest_filter("Tag", value, true));
6689    }
6690    if let Some(value) = &query.command {
6691        filters.push(rest_filter("Command", value, true));
6692    }
6693    append_required_rest_filters(&mut filters, info);
6694
6695    if !filters.is_empty() {
6696        root.insert("Filters".to_owned(), JsonValue::Array(filters));
6697    }
6698
6699    Ok(JsonValue::Object(root))
6700}
6701
6702fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
6703    if info
6704        .required_package_match_fields
6705        .iter()
6706        .any(|field| field.eq_ignore_ascii_case("Market"))
6707    {
6708        filters.push(rest_filter("Market", &default_market(), true));
6709    }
6710}
6711
6712fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
6713    serde_json::json!({
6714        "PackageMatchField": field,
6715        "RequestMatch": {
6716            "KeyWord": value,
6717            "MatchType": if exact { "Exact" } else { "Substring" },
6718        }
6719    })
6720}
6721
6722fn max_results(query: &PackageQuery) -> usize {
6723    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
6724}
6725
6726fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
6727    match semantics {
6728        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
6729        SearchSemantics::Single => max_results(query),
6730    }
6731}
6732
6733fn infer_preindexed_match_criteria_v2(
6734    connection: &Connection,
6735    row: &V2SearchRow,
6736    query: &PackageQuery,
6737    semantics: SearchSemantics,
6738) -> Result<Option<String>> {
6739    infer_match_criteria(
6740        &row.id,
6741        &row.name,
6742        row.moniker.as_deref(),
6743        query,
6744        semantics,
6745        |value| {
6746            find_mapped_value_v2(
6747                connection,
6748                "tags2",
6749                "tags2_map",
6750                "tag",
6751                row.package_rowid,
6752                value,
6753                query.exact,
6754            )
6755        },
6756        |value| {
6757            find_mapped_value_v2(
6758                connection,
6759                "commands2",
6760                "commands2_map",
6761                "command",
6762                row.package_rowid,
6763                value,
6764                query.exact,
6765            )
6766        },
6767    )
6768}
6769
6770fn infer_preindexed_match_criteria_v1(
6771    connection: &Connection,
6772    row: &V1SearchRow,
6773    query: &PackageQuery,
6774    semantics: SearchSemantics,
6775) -> Result<Option<String>> {
6776    infer_match_criteria(
6777        &row.id,
6778        &row.name,
6779        row.moniker.as_deref(),
6780        query,
6781        semantics,
6782        |value| {
6783            find_mapped_value_v1(
6784                connection,
6785                "tags",
6786                "tags_map",
6787                "tag",
6788                row.manifest_rowid,
6789                value,
6790                query.exact,
6791            )
6792        },
6793        |value| {
6794            find_mapped_value_v1(
6795                connection,
6796                "commands",
6797                "commands_map",
6798                "command",
6799                row.manifest_rowid,
6800                value,
6801                query.exact,
6802            )
6803        },
6804    )
6805}
6806
6807fn infer_match_criteria<FTag, FCommand>(
6808    id: &str,
6809    name: &str,
6810    moniker: Option<&str>,
6811    query: &PackageQuery,
6812    semantics: SearchSemantics,
6813    find_tag: FTag,
6814    find_command: FCommand,
6815) -> Result<Option<String>>
6816where
6817    FTag: FnOnce(&str) -> Result<Option<String>>,
6818    FCommand: FnOnce(&str) -> Result<Option<String>>,
6819{
6820    if let Some(value) = &query.tag {
6821        return Ok(Some(format_match_criteria("Tag", value)));
6822    }
6823    if let Some(value) = &query.command {
6824        return Ok(Some(format_match_criteria("Command", value)));
6825    }
6826    if let Some(value) = &query.moniker {
6827        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
6828    }
6829    if semantics == SearchSemantics::Single {
6830        return Ok(None);
6831    }
6832    if let Some(value) = &query.query {
6833        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
6834            return Ok(None);
6835        }
6836        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
6837            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
6838        }
6839        if let Some(tag) = find_tag(value)? {
6840            return Ok(Some(format_match_criteria("Tag", &tag)));
6841        }
6842        if let Some(command) = find_command(value)? {
6843            return Ok(Some(format_match_criteria("Command", &command)));
6844        }
6845    }
6846    Ok(None)
6847}
6848
6849fn find_mapped_value_v2(
6850    connection: &Connection,
6851    table_name: &str,
6852    map_table_name: &str,
6853    value_name: &str,
6854    package_rowid: i64,
6855    query: &str,
6856    exact: bool,
6857) -> Result<Option<String>> {
6858    let sql = format!(
6859        "SELECT {table_name}.{value_name} FROM {map_table_name} \
6860         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6861         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
6862    );
6863    let values = query_rows(
6864        connection,
6865        &sql,
6866        vec![
6867            SqlValue::Integer(package_rowid),
6868            SqlValue::Text(match_parameter(query, exact)),
6869        ],
6870        |row| row_string(row, 0),
6871    )?;
6872    Ok(select_best_text_match(values, query, exact))
6873}
6874
6875fn find_mapped_value_v1(
6876    connection: &Connection,
6877    table_name: &str,
6878    map_table_name: &str,
6879    value_name: &str,
6880    manifest_rowid: i64,
6881    query: &str,
6882    exact: bool,
6883) -> Result<Option<String>> {
6884    let sql = format!(
6885        "SELECT {table_name}.{value_name} FROM {map_table_name} \
6886         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6887         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
6888    );
6889    let values = query_rows(
6890        connection,
6891        &sql,
6892        vec![
6893            SqlValue::Integer(manifest_rowid),
6894            SqlValue::Text(match_parameter(query, exact)),
6895        ],
6896        |row| row_string(row, 0),
6897    )?;
6898    Ok(select_best_text_match(values, query, exact))
6899}
6900
6901fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
6902    if let Some(value) = &query.tag {
6903        return Some(format_match_criteria("Tag", value));
6904    }
6905    if let Some(value) = &query.command {
6906        return Some(format_match_criteria("Command", value));
6907    }
6908    if let Some(value) = &query.moniker {
6909        return Some(format_match_criteria("Moniker", value));
6910    }
6911    if semantics == SearchSemantics::Single {
6912        return None;
6913    }
6914    let value = query.query.as_deref()?;
6915    if json_string(item, "PackageIdentifier")
6916        .as_deref()
6917        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6918        || json_string(item, "PackageName")
6919            .as_deref()
6920            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6921    {
6922        return None;
6923    }
6924    json_string(item, "Moniker")
6925        .filter(|candidate| matches_text(candidate, value, query.exact))
6926        .map(|candidate| format_match_criteria("Moniker", &candidate))
6927}
6928
6929fn format_match_criteria(field: &str, value: &str) -> String {
6930    format!("{field}: {value}")
6931}
6932
6933fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6934    if exact {
6935        candidate.eq_ignore_ascii_case(query)
6936    } else {
6937        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6938    }
6939}
6940
6941fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6942    let mut score = 0;
6943
6944    if let Some(value) = &query.query {
6945        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6946        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6947        if let Some(moniker) = candidate.moniker.as_deref() {
6948            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6949        }
6950    }
6951
6952    if let Some(value) = &query.id {
6953        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6954    }
6955    if let Some(value) = &query.name {
6956        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6957    }
6958    if let Some(value) = &query.moniker
6959        && let Some(moniker) = candidate.moniker.as_deref()
6960    {
6961        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6962    }
6963    if let Some(value) = &query.tag
6964        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6965    {
6966        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6967    }
6968    if let Some(value) = &query.command
6969        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6970    {
6971        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6972    }
6973
6974    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6975        score += 5;
6976    }
6977    if search_match_has_unknown_version(candidate) {
6978        score = score.saturating_sub(10);
6979    }
6980
6981    score
6982}
6983
6984fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
6985    candidate
6986        .version
6987        .as_deref()
6988        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
6989}
6990
6991/// Builds the YAML manifest emitted next to the installer by `winget download`.
6992///
6993/// `documents` is the merged singleton JSON the source provided; we narrow the
6994/// `Installers` array down to just the resolved installer so the file describes
6995/// exactly the binary that was written, mirroring native winget behavior. The
6996/// typed `manifest` overrides identity fields (id, version, name, channel) on
6997/// the JSON because callers like `manifest_for_match` correct those after the
6998/// initial YAML parse.
6999fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
7000    let mut document = match documents {
7001        JsonValue::Object(map) => map.clone(),
7002        _ => serde_json::Map::new(),
7003    };
7004
7005    document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
7006    document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
7007    document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
7008    if !manifest.channel.is_empty() {
7009        document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
7010    } else {
7011        document.remove("Channel");
7012    }
7013
7014    // Replace the multi-installer array with the single resolved installer so
7015    // the emitted manifest describes the file the user actually downloaded.
7016    let mut single_installer = serde_json::Map::new();
7017    if let Some(arch) = installer.architecture.as_ref() {
7018        single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
7019    }
7020    if let Some(installer_type) = installer.installer_type.as_ref() {
7021        single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
7022    }
7023    if let Some(url) = installer.url.as_ref() {
7024        single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
7025    }
7026    if let Some(sha) = installer.sha256.as_ref() {
7027        single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
7028    }
7029    if let Some(scope) = installer.scope.as_ref() {
7030        single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
7031    }
7032    if let Some(locale) = installer.locale.as_ref() {
7033        single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
7034    }
7035    if let Some(product_code) = installer.product_code.as_ref() {
7036        single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
7037    }
7038    if let Some(release_date) = installer.release_date.as_ref() {
7039        single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
7040    }
7041
7042    document.insert(
7043        "Installers".to_owned(),
7044        JsonValue::Array(vec![JsonValue::Object(single_installer)]),
7045    );
7046    document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
7047
7048    let value = JsonValue::Object(document);
7049    serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
7050}
7051
7052/// Computes the file name `winget download` uses for the YAML manifest
7053/// emitted next to the installer:
7054/// `{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml`.
7055/// Mirrors `GetInstallerDownloadOnlyFileName` in winget-cli's DownloadFlow.
7056fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
7057    let mut filename = derive_download_filename_stem(manifest, installer);
7058    filename.push_str(".yaml");
7059    filename
7060}
7061
7062fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
7063    let mut stem = sanitize_filename_component(&manifest.name);
7064    if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
7065        stem.push('_');
7066        stem.push_str(&sanitize_filename_component(&manifest.version));
7067    }
7068    if let Some(scope) = installer.scope.as_deref() {
7069        stem.push('_');
7070        stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
7071    }
7072    let architecture = installer.architecture.as_deref().unwrap_or("neutral");
7073    stem.push('_');
7074    stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
7075
7076    let installer_type = installer.installer_type.as_deref().unwrap_or("");
7077    if !installer_type.is_empty() {
7078        stem.push('_');
7079        stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
7080    }
7081    if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
7082        stem.push('_');
7083        stem.push_str(&sanitize_filename_component(locale));
7084    }
7085
7086    stem
7087}
7088
7089fn capitalize_first(value: &str) -> String {
7090    let mut chars = value.chars();
7091    match chars.next() {
7092        Some(first) => {
7093            let mut out = first.to_ascii_uppercase().to_string();
7094            out.push_str(&chars.as_str().to_ascii_lowercase());
7095            out
7096        }
7097        None => String::new(),
7098    }
7099}
7100
7101fn format_architecture(value: &str) -> String {
7102    match value.to_ascii_lowercase().as_str() {
7103        "x64" => "X64".to_owned(),
7104        "x86" => "X86".to_owned(),
7105        "arm" => "Arm".to_owned(),
7106        "arm64" => "Arm64".to_owned(),
7107        "neutral" => "Neutral".to_owned(),
7108        other => capitalize_first(other),
7109    }
7110}
7111
7112fn sanitize_filename_component(value: &str) -> String {
7113    value
7114        .chars()
7115        .map(|ch| match ch {
7116            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
7117            ch if ch.is_control() => '_',
7118            ch => ch,
7119        })
7120        .collect()
7121}
7122
7123fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
7124    if manifest_path.is_file() {
7125        return Ok(manifest_path.to_path_buf());
7126    }
7127
7128    if !manifest_path.is_dir() {
7129        bail!("Manifest path not found: {}", manifest_path.display());
7130    }
7131
7132    let candidate = fs::read_dir(manifest_path)?
7133        .filter_map(|entry| entry.ok())
7134        .map(|entry| entry.path())
7135        .filter(|path| {
7136            path.extension()
7137                .and_then(|value| value.to_str())
7138                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
7139        })
7140        .min();
7141
7142    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
7143}
7144
7145fn score_text_match(
7146    candidate: &str,
7147    query: &str,
7148    exact: bool,
7149    exact_score: usize,
7150    prefix_score: usize,
7151    substring_score: usize,
7152) -> usize {
7153    if candidate.eq_ignore_ascii_case(query) {
7154        return exact_score;
7155    }
7156    if exact {
7157        return 0;
7158    }
7159
7160    let candidate_lower = candidate.to_ascii_lowercase();
7161    let query_lower = query.to_ascii_lowercase();
7162    if candidate_lower.starts_with(&query_lower) {
7163        prefix_score
7164    } else if candidate_lower.contains(&query_lower) {
7165        substring_score
7166    } else {
7167        0
7168    }
7169}
7170
7171fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
7172where
7173    I: IntoIterator<Item = String>,
7174{
7175    values
7176        .into_iter()
7177        .filter_map(|candidate| {
7178            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
7179            (score > 0).then_some((score, candidate))
7180        })
7181        .max_by(|(left_score, left_value), (right_score, right_value)| {
7182            left_score
7183                .cmp(right_score)
7184                .then_with(|| right_value.len().cmp(&left_value.len()))
7185                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
7186        })
7187        .map(|(_, candidate)| candidate)
7188}
7189
7190fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
7191    let (field, value) = criteria.split_once(": ")?;
7192    Some((field, value))
7193}
7194
7195fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
7196    let versions = item
7197        .get("Versions")
7198        .and_then(JsonValue::as_array)
7199        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
7200    let mut result = Vec::new();
7201    for version in versions {
7202        let value = json_string(version, "PackageVersion")
7203            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
7204        let channel = json_string(version, "Channel").unwrap_or_default();
7205        result.push(VersionKey {
7206            version: value,
7207            channel,
7208        });
7209    }
7210    Ok(result)
7211}
7212
7213fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
7214    let mut merged = YamlMapping::new();
7215    let mut documents = Vec::new();
7216    for document in serde_yaml::Deserializer::from_slice(bytes) {
7217        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
7218        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
7219        if let Some(mapping) = value.as_mapping() {
7220            merge_yaml_mapping(&mut merged, mapping);
7221        }
7222    }
7223
7224    let id = yaml_string_from_root(&merged, "PackageIdentifier")
7225        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
7226    let version =
7227        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
7228    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
7229
7230    let installers = parse_yaml_installers(&merged);
7231    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
7232    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7233
7234    Ok((
7235        Manifest {
7236            id,
7237            name,
7238            version,
7239            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
7240            publisher: yaml_localized_string(&merged, "Publisher"),
7241            description: yaml_localized_string(&merged, "Description")
7242                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
7243            moniker: yaml_string_from_root(&merged, "Moniker"),
7244            package_url: yaml_localized_string(&merged, "PackageUrl"),
7245            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
7246            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
7247            license: yaml_localized_string(&merged, "License"),
7248            license_url: yaml_localized_string(&merged, "LicenseUrl"),
7249            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
7250            author: yaml_localized_string(&merged, "Author"),
7251            copyright: yaml_localized_string(&merged, "Copyright"),
7252            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
7253            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
7254            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
7255            tags: yaml_string_list(&merged, "Tags"),
7256            agreements: yaml_agreement_list(&merged),
7257            package_dependencies: yaml_package_dependencies(&merged),
7258            documentation: yaml_documentation_list(&merged),
7259            icons: yaml_icon_list(&merged),
7260            installers,
7261            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7262        },
7263        collapse_structured_document(&JsonValue::Array(documents)),
7264    ))
7265}
7266
7267fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
7268    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
7269}
7270
7271fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
7272    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
7273    let data = root
7274        .get("Data")
7275        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
7276    let versions = data
7277        .get("Versions")
7278        .and_then(JsonValue::as_array)
7279        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
7280    let selected = versions
7281        .iter()
7282        .find(|item| {
7283            json_string(item, "PackageVersion").as_deref() == Some(version)
7284                && json_string(item, "Channel").unwrap_or_default() == channel
7285        })
7286        .or_else(|| versions.first())
7287        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
7288
7289    let default_locale = selected
7290        .get("DefaultLocale")
7291        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
7292    let package_default_locale = data.get("DefaultLocale");
7293    let name = json_string(default_locale, "PackageName")
7294        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
7295    let installer_switch_defaults = json_installer_switches(selected);
7296    let top_platforms = json_string_list(selected, "Platform");
7297    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
7298    let top_installer_type = json_string(selected, "InstallerType");
7299    let top_nested_installer_type = json_string(selected, "NestedInstallerType");
7300    let top_nested_installer_files = json_nested_installer_files(selected);
7301
7302    let installers = selected
7303        .get("Installers")
7304        .and_then(JsonValue::as_array)
7305        .map(|items| {
7306            items
7307                .iter()
7308                .map(|item| Installer {
7309                    platforms: {
7310                        let platforms = json_string_list(item, "Platform");
7311                        if platforms.is_empty() {
7312                            top_platforms.clone()
7313                        } else {
7314                            platforms
7315                        }
7316                    },
7317                    minimum_os_version: json_string(item, "MinimumOSVersion")
7318                        .or_else(|| top_minimum_os_version.clone()),
7319                    architecture: json_string(item, "Architecture"),
7320                    installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
7321                    nested_installer_type: json_string(item, "NestedInstallerType")
7322                        .or_else(|| top_nested_installer_type.clone()),
7323                    nested_installer_files: {
7324                        let files = json_nested_installer_files(item);
7325                        if files.is_empty() {
7326                            top_nested_installer_files.clone()
7327                        } else {
7328                            files
7329                        }
7330                    },
7331                    url: json_string(item, "InstallerUrl"),
7332                    sha256: json_string(item, "InstallerSha256"),
7333                    product_code: json_string(item, "ProductCode"),
7334                    locale: json_string(item, "InstallerLocale"),
7335                    scope: json_string(item, "Scope"),
7336                    release_date: json_string(item, "ReleaseDate"),
7337                    package_family_name: json_string(item, "PackageFamilyName"),
7338                    upgrade_code: json_string(item, "UpgradeCode"),
7339                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
7340                    commands: json_string_list(item, "Commands"),
7341                    package_dependencies: json_package_dependencies(item),
7342                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
7343                })
7344                .collect::<Vec<_>>()
7345        })
7346        .unwrap_or_default();
7347    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
7348    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7349    let mut icons = json_icon_list(default_locale);
7350    if icons.is_empty() {
7351        icons = package_default_locale.map(json_icon_list).unwrap_or_default();
7352    }
7353
7354    let manifest = Manifest {
7355        id: package_id.to_owned(),
7356        name,
7357        version: version.to_owned(),
7358        channel: channel.to_owned(),
7359        publisher: json_string(default_locale, "Publisher"),
7360        description: json_string(default_locale, "Description")
7361            .or_else(|| json_string(default_locale, "ShortDescription")),
7362        moniker: json_string(default_locale, "Moniker"),
7363        package_url: json_string(default_locale, "PackageUrl"),
7364        publisher_url: json_string(default_locale, "PublisherUrl"),
7365        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
7366        license: json_string(default_locale, "License"),
7367        license_url: json_string(default_locale, "LicenseUrl"),
7368        privacy_url: json_string(default_locale, "PrivacyUrl"),
7369        author: json_string(default_locale, "Author"),
7370        copyright: json_string(default_locale, "Copyright"),
7371        copyright_url: json_string(default_locale, "CopyrightUrl"),
7372        release_notes: json_string(default_locale, "ReleaseNotes"),
7373        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
7374        tags: json_string_list(default_locale, "Tags"),
7375        agreements: json_agreement_list(default_locale),
7376        package_dependencies: json_package_dependencies(selected),
7377        documentation: json_documentation_list(default_locale),
7378        icons,
7379        installers,
7380        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7381    };
7382
7383    Ok((
7384        manifest,
7385        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
7386    ))
7387}
7388
7389fn collapse_structured_document(document: &JsonValue) -> JsonValue {
7390    match document {
7391        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
7392            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
7393        }
7394        JsonValue::Array(documents) => merge_manifest_documents(documents),
7395        JsonValue::Object(_) if is_merged_manifest_document(document) => {
7396            merge_manifest_documents(&split_merged_manifest_document(document))
7397        }
7398        _ => document.clone(),
7399    }
7400}
7401
7402fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
7403    documents.iter().map(collapse_structured_document).collect()
7404}
7405
7406fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
7407    let Some(root) = document.as_object_mut() else {
7408        return document;
7409    };
7410
7411    root.insert(
7412        "IconUrl".to_owned(),
7413        manifest
7414            .icon_url()
7415            .map(|url| JsonValue::String(url.to_owned()))
7416            .unwrap_or(JsonValue::Null),
7417    );
7418    root.insert(
7419        "Icon".to_owned(),
7420        manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
7421    );
7422    root.entry("Icons".to_owned())
7423        .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
7424
7425    document
7426}
7427
7428fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
7429    serde_json::to_value(icon).expect("package icon serialization cannot fail")
7430}
7431
7432fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
7433    let version = documents.iter().find(|document| {
7434        document
7435            .get("ManifestType")
7436            .and_then(JsonValue::as_str)
7437            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
7438    });
7439    let default_locale = documents.iter().find(|document| {
7440        document
7441            .get("ManifestType")
7442            .and_then(JsonValue::as_str)
7443            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
7444    });
7445    let installer = documents.iter().find(|document| {
7446        document
7447            .get("ManifestType")
7448            .and_then(JsonValue::as_str)
7449            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
7450    });
7451
7452    let mut singleton = JsonMap::new();
7453    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
7454    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
7455    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
7456
7457    if !singleton.contains_key("PackageLocale")
7458        && let Some(package_locale) = default_locale
7459            .and_then(|document| json_string(document, "PackageLocale"))
7460            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
7461    {
7462        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7463    }
7464
7465    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
7466    singleton.insert(
7467        "ManifestVersion".to_owned(),
7468        JsonValue::String(
7469            installer
7470                .and_then(|document| json_string(document, "ManifestVersion"))
7471                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
7472                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
7473                .unwrap_or_else(|| "1.10.0".to_owned()),
7474        ),
7475    );
7476    singleton.remove("DefaultLocale");
7477
7478    JsonValue::Object(singleton)
7479}
7480
7481fn is_merged_manifest_document(document: &JsonValue) -> bool {
7482    document
7483        .get("ManifestType")
7484        .and_then(JsonValue::as_str)
7485        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
7486}
7487
7488fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
7489    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
7490    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
7491    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7492    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
7493
7494    let version_document = serde_json::json!({
7495        "PackageIdentifier": package_identifier,
7496        "PackageVersion": package_version,
7497        "DefaultLocale": package_locale,
7498        "ManifestType": "version",
7499        "ManifestVersion": manifest_version,
7500    });
7501
7502    let mut default_locale_document = project_manifest_document(
7503        merged,
7504        &[
7505            "PackageIdentifier",
7506            "PackageVersion",
7507            "PackageLocale",
7508            "Publisher",
7509            "PublisherUrl",
7510            "PublisherSupportUrl",
7511            "PrivacyUrl",
7512            "Author",
7513            "PackageName",
7514            "PackageUrl",
7515            "License",
7516            "LicenseUrl",
7517            "Copyright",
7518            "CopyrightUrl",
7519            "ShortDescription",
7520            "Description",
7521            "Moniker",
7522            "Tags",
7523            "Agreements",
7524            "ReleaseNotes",
7525            "ReleaseNotesUrl",
7526            "PurchaseUrl",
7527            "InstallationNotes",
7528            "Documentations",
7529            "Icons",
7530        ],
7531    );
7532    default_locale_document.insert(
7533        "PackageIdentifier".to_owned(),
7534        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7535    );
7536    default_locale_document.insert(
7537        "PackageVersion".to_owned(),
7538        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7539    );
7540    default_locale_document.insert(
7541        "PackageLocale".to_owned(),
7542        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
7543    );
7544    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7545    default_locale_document.insert(
7546        "ManifestVersion".to_owned(),
7547        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7548    );
7549
7550    let mut installer_document = project_manifest_document(
7551        merged,
7552        &[
7553            "PackageIdentifier",
7554            "PackageVersion",
7555            "Channel",
7556            "InstallerLocale",
7557            "Platform",
7558            "MinimumOSVersion",
7559            "InstallerType",
7560            "NestedInstallerType",
7561            "NestedInstallerFiles",
7562            "Scope",
7563            "InstallModes",
7564            "InstallerSwitches",
7565            "InstallerSuccessCodes",
7566            "ExpectedReturnCodes",
7567            "UpgradeBehavior",
7568            "Commands",
7569            "Protocols",
7570            "FileExtensions",
7571            "Dependencies",
7572            "PackageFamilyName",
7573            "ProductCode",
7574            "Capabilities",
7575            "RestrictedCapabilities",
7576            "Markets",
7577            "InstallerAbortsTerminal",
7578            "ReleaseDate",
7579            "InstallLocationRequired",
7580            "RequireExplicitUpgrade",
7581            "DisplayInstallWarnings",
7582            "UnsupportedOSArchitectures",
7583            "UnsupportedArguments",
7584            "AppsAndFeaturesEntries",
7585            "ElevationRequirement",
7586            "InstallationMetadata",
7587            "DownloadCommandProhibited",
7588            "RepairBehavior",
7589            "ArchiveBinariesDependOnPath",
7590            "Authentication",
7591            "Installers",
7592        ],
7593    );
7594    installer_document.insert(
7595        "PackageIdentifier".to_owned(),
7596        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7597    );
7598    installer_document.insert(
7599        "PackageVersion".to_owned(),
7600        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7601    );
7602    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7603    installer_document.insert(
7604        "ManifestVersion".to_owned(),
7605        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7606    );
7607
7608    vec![
7609        version_document,
7610        JsonValue::Object(default_locale_document),
7611        JsonValue::Object(installer_document),
7612    ]
7613}
7614
7615fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
7616    let mut result = JsonMap::new();
7617    if let Some(source) = source.as_object() {
7618        for key in keys {
7619            if let Some(value) = source.get(*key) {
7620                result.insert((*key).to_owned(), value.clone());
7621            }
7622        }
7623    }
7624
7625    result
7626}
7627
7628fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
7629    if let Some(source) = source.and_then(JsonValue::as_object) {
7630        for key in keys {
7631            if let Some(value) = source.get(*key) {
7632                target.insert((*key).to_owned(), value.clone());
7633            }
7634        }
7635    }
7636}
7637
7638fn copy_all_manifest_keys_except(
7639    target: &mut JsonMap<String, JsonValue>,
7640    source: Option<&JsonValue>,
7641    excluded_keys: &[&str],
7642) {
7643    if let Some(source) = source.and_then(JsonValue::as_object) {
7644        for (key, value) in source {
7645            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
7646                target.insert(key.clone(), value.clone());
7647            }
7648        }
7649    }
7650}
7651
7652fn build_rest_manifest_documents(
7653    data: &JsonValue,
7654    installer_source: &JsonValue,
7655    default_locale: &JsonValue,
7656    package_id: &str,
7657    version: &str,
7658    channel: &str,
7659) -> JsonValue {
7660    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
7661    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7662    let manifest_version = json_string(installer_source, "ManifestVersion")
7663        .or_else(|| json_string(default_locale, "ManifestVersion"))
7664        .or_else(|| json_string(data, "ManifestVersion"))
7665        .unwrap_or_else(|| "1.10.0".to_owned());
7666
7667    let mut version_document = JsonMap::new();
7668    version_document.insert(
7669        "PackageIdentifier".to_owned(),
7670        JsonValue::String(package_identifier.clone()),
7671    );
7672    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7673    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
7674    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
7675    version_document.insert(
7676        "ManifestVersion".to_owned(),
7677        JsonValue::String(manifest_version.clone()),
7678    );
7679
7680    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
7681    default_locale_document.insert(
7682        "PackageIdentifier".to_owned(),
7683        JsonValue::String(package_identifier.clone()),
7684    );
7685    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7686    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7687    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7688    default_locale_document.insert(
7689        "ManifestVersion".to_owned(),
7690        JsonValue::String(manifest_version.clone()),
7691    );
7692
7693    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
7694    installer_document.remove("DefaultLocale");
7695    installer_document.remove("Locales");
7696    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
7697    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7698    if !channel.is_empty() {
7699        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
7700    }
7701    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7702    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
7703
7704    let documents = vec![
7705        JsonValue::Object(version_document),
7706        JsonValue::Object(default_locale_document),
7707        JsonValue::Object(installer_document),
7708    ];
7709
7710    merge_manifest_documents(&documents)
7711}
7712
7713fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
7714    let base = installer_defaults(root);
7715    let base_switches = yaml_installer_switches(root);
7716    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
7717        let installers = items
7718            .iter()
7719            .filter_map(YamlValue::as_mapping)
7720            .map(|item| {
7721                let mut merged = base.clone();
7722                merge_yaml_mapping(&mut merged, item);
7723                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
7724                installer_from_yaml(&merged, switches)
7725            })
7726            .collect::<Vec<_>>();
7727        if !installers.is_empty() {
7728            return installers;
7729        }
7730    }
7731
7732    if base.is_empty() {
7733        Vec::new()
7734    } else {
7735        vec![installer_from_yaml(&base, base_switches)]
7736    }
7737}
7738
7739fn installer_defaults(root: &YamlMapping) -> YamlMapping {
7740    let keys = [
7741        "Architecture",
7742        "InstallerType",
7743        "NestedInstallerType",
7744        "NestedInstallerFiles",
7745        "InstallerUrl",
7746        "InstallerSha256",
7747        "ProductCode",
7748        "InstallerLocale",
7749        "Platform",
7750        "MinimumOSVersion",
7751        "Scope",
7752        "ReleaseDate",
7753        "PackageFamilyName",
7754        "UpgradeCode",
7755        "Commands",
7756    ];
7757    let mut defaults = YamlMapping::new();
7758    for key in keys {
7759        if let Some(value) = root.get(YamlValue::from(key)) {
7760            defaults.insert(YamlValue::from(key), value.clone());
7761        }
7762    }
7763    defaults
7764}
7765
7766fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
7767    Installer {
7768        architecture: yaml_string(root, "Architecture"),
7769        installer_type: yaml_string(root, "InstallerType"),
7770        nested_installer_type: yaml_string(root, "NestedInstallerType"),
7771        nested_installer_files: yaml_nested_installer_files(root),
7772        url: yaml_string(root, "InstallerUrl"),
7773        sha256: yaml_string(root, "InstallerSha256"),
7774        product_code: yaml_string(root, "ProductCode"),
7775        locale: yaml_string(root, "InstallerLocale"),
7776        scope: yaml_string(root, "Scope"),
7777        release_date: yaml_string(root, "ReleaseDate"),
7778        package_family_name: yaml_string(root, "PackageFamilyName"),
7779        upgrade_code: yaml_string(root, "UpgradeCode"),
7780        platforms: yaml_string_list(root, "Platform"),
7781        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
7782        switches,
7783        commands: yaml_string_list(root, "Commands"),
7784        package_dependencies: yaml_package_dependencies(root),
7785        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
7786    }
7787}
7788
7789/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
7790/// the legacy `True`/`False` variants the winget catalog mixes in.
7791fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
7792    let Some(value) = root.get(YamlValue::from(key)) else {
7793        return false;
7794    };
7795    match value {
7796        YamlValue::Bool(b) => *b,
7797        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
7798        _ => false,
7799    }
7800}
7801
7802fn json_bool(value: &JsonValue, key: &str) -> bool {
7803    match value.get(key) {
7804        Some(JsonValue::Bool(b)) => *b,
7805        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
7806        _ => false,
7807    }
7808}
7809
7810fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
7811    let mapping = root
7812        .get(YamlValue::from("InstallerSwitches"))
7813        .or_else(|| root.get(YamlValue::from("Switches")))
7814        .and_then(YamlValue::as_mapping);
7815    yaml_installer_switches_from_mapping(mapping)
7816}
7817
7818fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
7819    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
7820
7821    InstallerSwitches {
7822        silent: string("Silent"),
7823        silent_with_progress: string("SilentWithProgress"),
7824        interactive: string("Interactive"),
7825        custom: string("Custom"),
7826        log: string("Log"),
7827        install_location: string("InstallLocation"),
7828    }
7829}
7830
7831fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
7832    yaml_string(root, key).or_else(|| {
7833        root.get(YamlValue::from("DefaultLocale"))
7834            .and_then(YamlValue::as_mapping)
7835            .and_then(|mapping| yaml_string(mapping, key))
7836    })
7837}
7838
7839fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
7840    yaml_string(root, key)
7841}
7842
7843fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
7844    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
7845}
7846
7847fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
7848    match value {
7849        YamlValue::Null => None,
7850        YamlValue::Bool(value) => Some(value.to_string()),
7851        YamlValue::Number(value) => Some(value.to_string()),
7852        YamlValue::String(value) => Some(value.clone()),
7853        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
7854        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
7855    }
7856}
7857
7858fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
7859    root.get(YamlValue::from(key))
7860        .and_then(YamlValue::as_sequence)
7861        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
7862        .unwrap_or_default()
7863}
7864
7865fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
7866    root.get(YamlValue::from("Documentations"))
7867        .and_then(YamlValue::as_sequence)
7868        .map(|items| {
7869            items
7870                .iter()
7871                .filter_map(YamlValue::as_mapping)
7872                .filter_map(|item| {
7873                    let url = yaml_string(item, "DocumentUrl")?;
7874                    Some(Documentation {
7875                        label: yaml_string(item, "DocumentLabel"),
7876                        url,
7877                    })
7878                })
7879                .collect()
7880        })
7881        .unwrap_or_default()
7882}
7883
7884fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
7885    root.get(YamlValue::from("Icons"))
7886        .and_then(YamlValue::as_sequence)
7887        .map(|items| {
7888            items
7889                .iter()
7890                .filter_map(YamlValue::as_mapping)
7891                .filter_map(|item| {
7892                    let icon = PackageIcon {
7893                        icon_url: yaml_string(item, "IconUrl"),
7894                        icon_file_type: yaml_string(item, "IconFileType"),
7895                        icon_resolution: yaml_string(item, "IconResolution"),
7896                        icon_theme: yaml_string(item, "IconTheme"),
7897                        icon_sha256: yaml_string(item, "IconSha256"),
7898                    };
7899                    (!icon.is_empty()).then_some(icon)
7900                })
7901                .collect()
7902        })
7903        .unwrap_or_default()
7904}
7905
7906fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
7907    root.get(YamlValue::from("Agreements"))
7908        .and_then(YamlValue::as_sequence)
7909        .map(|items| {
7910            items
7911                .iter()
7912                .filter_map(YamlValue::as_mapping)
7913                .map(|item| PackageAgreement {
7914                    label: yaml_string(item, "AgreementLabel"),
7915                    text: yaml_string(item, "Agreement"),
7916                    url: yaml_string(item, "AgreementUrl"),
7917                })
7918                .collect()
7919        })
7920        .unwrap_or_default()
7921}
7922
7923fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
7924    root.get(YamlValue::from("Dependencies"))
7925        .and_then(YamlValue::as_mapping)
7926        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
7927        .and_then(YamlValue::as_sequence)
7928        .map(|items| {
7929            items
7930                .iter()
7931                .filter_map(YamlValue::as_mapping)
7932                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
7933                .collect()
7934        })
7935        .unwrap_or_default()
7936}
7937
7938fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
7939    root.get(YamlValue::from("NestedInstallerFiles"))
7940        .and_then(YamlValue::as_sequence)
7941        .map(|items| {
7942            items
7943                .iter()
7944                .filter_map(YamlValue::as_mapping)
7945                .filter_map(|item| {
7946                    let relative = yaml_string(item, "RelativeFilePath")?;
7947                    Some(NestedInstallerFile {
7948                        relative_file_path: relative,
7949                        portable_command_alias: yaml_string(item, "PortableCommandAlias"),
7950                    })
7951                })
7952                .collect()
7953        })
7954        .unwrap_or_default()
7955}
7956
7957fn json_string(value: &JsonValue, key: &str) -> Option<String> {
7958    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
7959}
7960
7961fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
7962    value
7963        .get(key)
7964        .and_then(JsonValue::as_array)
7965        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7966        .unwrap_or_default()
7967}
7968
7969fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7970    value
7971        .get("Documentations")
7972        .and_then(JsonValue::as_array)
7973        .map(|items| {
7974            items
7975                .iter()
7976                .filter_map(|item| {
7977                    let url = json_string(item, "DocumentUrl")?;
7978                    Some(Documentation {
7979                        label: json_string(item, "DocumentLabel"),
7980                        url,
7981                    })
7982                })
7983                .collect()
7984        })
7985        .unwrap_or_default()
7986}
7987
7988fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
7989    value
7990        .get("Icons")
7991        .and_then(JsonValue::as_array)
7992        .map(|items| {
7993            items
7994                .iter()
7995                .filter_map(|item| {
7996                    let icon = PackageIcon {
7997                        icon_url: json_string(item, "IconUrl"),
7998                        icon_file_type: json_string(item, "IconFileType"),
7999                        icon_resolution: json_string(item, "IconResolution"),
8000                        icon_theme: json_string(item, "IconTheme"),
8001                        icon_sha256: json_string(item, "IconSha256"),
8002                    };
8003                    (!icon.is_empty()).then_some(icon)
8004                })
8005                .collect()
8006        })
8007        .unwrap_or_default()
8008}
8009
8010fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
8011    value
8012        .get("Agreements")
8013        .and_then(JsonValue::as_array)
8014        .map(|items| {
8015            items
8016                .iter()
8017                .map(|item| PackageAgreement {
8018                    label: json_string(item, "AgreementLabel"),
8019                    text: json_string(item, "Agreement"),
8020                    url: json_string(item, "AgreementUrl"),
8021                })
8022                .collect()
8023        })
8024        .unwrap_or_default()
8025}
8026
8027fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
8028    value
8029        .get("Dependencies")
8030        .and_then(JsonValue::as_object)
8031        .and_then(|deps| deps.get("PackageDependencies"))
8032        .and_then(JsonValue::as_array)
8033        .map(|items| {
8034            items
8035                .iter()
8036                .filter_map(|item| json_string(item, "PackageIdentifier"))
8037                .collect()
8038        })
8039        .unwrap_or_default()
8040}
8041
8042fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
8043    value
8044        .get("NestedInstallerFiles")
8045        .and_then(JsonValue::as_array)
8046        .map(|items| {
8047            items
8048                .iter()
8049                .filter_map(|item| {
8050                    let relative = json_string(item, "RelativeFilePath")?;
8051                    Some(NestedInstallerFile {
8052                        relative_file_path: relative,
8053                        portable_command_alias: json_string(item, "PortableCommandAlias"),
8054                    })
8055                })
8056                .collect()
8057        })
8058        .unwrap_or_default()
8059}
8060
8061fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
8062    let switches = value
8063        .get("InstallerSwitches")
8064        .or_else(|| value.get("Switches"))
8065        .and_then(JsonValue::as_object);
8066
8067    let string = |key: &str| {
8068        switches
8069            .and_then(|switches| switches.get(key))
8070            .and_then(JsonValue::as_str)
8071            .map(str::to_owned)
8072    };
8073
8074    InstallerSwitches {
8075        silent: string("Silent"),
8076        silent_with_progress: string("SilentWithProgress"),
8077        interactive: string("Interactive"),
8078        custom: string("Custom"),
8079        log: string("Log"),
8080        install_location: string("InstallLocation"),
8081    }
8082}
8083
8084fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
8085    for (key, value) in source {
8086        target.insert(key.clone(), value.clone());
8087    }
8088}
8089
8090fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
8091    let reader = Cursor::new(bytes.to_vec());
8092    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
8093    let mut file = archive
8094        .by_name(member_name)
8095        .with_context(|| format!("zip payload missing {member_name}"))?;
8096    let mut output = Vec::new();
8097    file.read_to_end(&mut output)
8098        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
8099    Ok(output)
8100}
8101
8102fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
8103    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
8104        return Ok(output);
8105    }
8106
8107    if !bytes.starts_with(b"CK")
8108        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
8109    {
8110        return decompress_mszyml_payload(&bytes[offset + 2..]);
8111    }
8112
8113    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
8114}
8115
8116fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
8117    let mut decoder = flate2::read::DeflateDecoder::new(payload);
8118    let mut output = String::new();
8119    decoder.read_to_string(&mut output)?;
8120    Ok(output)
8121}
8122
8123fn cache_control_max_age(response: &Response) -> u64 {
8124    response
8125        .headers()
8126        .get(reqwest::header::CACHE_CONTROL)
8127        .and_then(|value| value.to_str().ok())
8128        .and_then(|header| {
8129            header.split(',').find_map(|part| {
8130                let trimmed = part.trim();
8131                trimmed
8132                    .strip_prefix("max-age=")
8133                    .and_then(|value| value.parse::<u64>().ok())
8134            })
8135        })
8136        .unwrap_or(60)
8137}
8138
8139fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
8140    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
8141        let (major, minor) = major_minor(candidate);
8142        server_versions.iter().any(|version| {
8143            let (server_major, server_minor) = major_minor(version);
8144            server_major == major && server_minor == minor
8145        })
8146    })
8147}
8148
8149fn major_minor(version: &str) -> (String, String) {
8150    let mut parts = version.split('.');
8151    (
8152        parts.next().unwrap_or_default().to_owned(),
8153        parts.next().unwrap_or_default().to_owned(),
8154    )
8155}
8156
8157fn default_market() -> String {
8158    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
8159}
8160
8161fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
8162    let requested_locale = query.locale.as_deref();
8163    let requested_architecture = query.installer_architecture.as_deref();
8164    let requested_type = query.installer_type.as_deref();
8165    let requested_scope = query.install_scope.as_deref();
8166    let requested_platform = query.platform.as_deref();
8167    let requested_os_version = query.os_version.as_deref();
8168    let system_architecture = current_architecture();
8169    let system_platform = current_platform();
8170    let current_os_version = current_os_version();
8171
8172    installers
8173        .iter()
8174        .enumerate()
8175        .filter(|(_, installer)| {
8176            installer_matches_requested(
8177                installer,
8178                requested_type,
8179                requested_scope,
8180                requested_platform,
8181                requested_os_version,
8182                system_platform,
8183                current_os_version.as_deref(),
8184            )
8185        })
8186        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
8187        .max_by_key(|(index, installer)| {
8188            (
8189                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
8190                std::cmp::Reverse(*index),
8191            )
8192        })
8193        .map(|(_, installer)| installer)
8194        .cloned()
8195}
8196
8197fn installer_matches_requested(
8198    installer: &Installer,
8199    requested_type: Option<&str>,
8200    requested_scope: Option<&str>,
8201    requested_platform: Option<&str>,
8202    requested_os_version: Option<&str>,
8203    system_platform: Option<&str>,
8204    current_os_version: Option<&str>,
8205) -> bool {
8206    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
8207        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
8208        && installer_matches_platform(installer, requested_platform, system_platform)
8209        && installer_matches_os_version(installer, requested_os_version, current_os_version)
8210}
8211
8212fn installer_matches_platform(
8213    installer: &Installer,
8214    requested_platform: Option<&str>,
8215    system_platform: Option<&str>,
8216) -> bool {
8217    if installer.platforms.is_empty() {
8218        return true;
8219    }
8220
8221    let Some(effective_platform) = requested_platform.or(system_platform) else {
8222        return true;
8223    };
8224
8225    installer
8226        .platforms
8227        .iter()
8228        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
8229}
8230
8231fn installer_matches_os_version(
8232    installer: &Installer,
8233    requested_os_version: Option<&str>,
8234    current_os_version: Option<&str>,
8235) -> bool {
8236    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
8237        return true;
8238    };
8239
8240    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
8241        return true;
8242    };
8243
8244    match (
8245        parse_dotted_version(minimum_os_version),
8246        parse_dotted_version(actual_os_version),
8247    ) {
8248        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
8249        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
8250    }
8251}
8252
8253fn installer_matches_architecture(
8254    installer: &Installer,
8255    requested_architecture: Option<&str>,
8256    system_architecture: &str,
8257) -> bool {
8258    let Some(architecture) = installer.architecture.as_deref() else {
8259        return true;
8260    };
8261
8262    let architecture = architecture.to_ascii_lowercase();
8263    if let Some(requested) = requested_architecture {
8264        return architecture.eq_ignore_ascii_case(requested);
8265    }
8266
8267    preferred_architectures(system_architecture)
8268        .iter()
8269        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
8270}
8271
8272fn installer_rank(
8273    installer: &Installer,
8274    requested_locale: Option<&str>,
8275    requested_architecture: Option<&str>,
8276    system_architecture: &str,
8277) -> (i32, i32, i32) {
8278    let architecture_rank = architecture_rank(
8279        installer.architecture.as_deref(),
8280        requested_architecture.unwrap_or(system_architecture),
8281        requested_architecture.is_some(),
8282        system_architecture,
8283    );
8284    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
8285    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
8286    (architecture_rank, locale_rank, command_rank)
8287}
8288
8289fn architecture_rank(
8290    installer_architecture: Option<&str>,
8291    preferred_architecture: &str,
8292    strict: bool,
8293    system_architecture: &str,
8294) -> i32 {
8295    let Some(value) = installer_architecture else {
8296        return 0;
8297    };
8298    if value.eq_ignore_ascii_case(preferred_architecture) {
8299        return 5;
8300    }
8301    if value.eq_ignore_ascii_case("neutral") {
8302        return 4;
8303    }
8304    if strict {
8305        return -1;
8306    }
8307
8308    preferred_architectures(system_architecture)
8309        .iter()
8310        .rev()
8311        .position(|candidate| value.eq_ignore_ascii_case(candidate))
8312        .and_then(|index| i32::try_from(index).ok())
8313        .and_then(|index| index.checked_add(1))
8314        .unwrap_or(-1)
8315}
8316
8317/// True when at least one installer in `manifest.installers` targets an
8318/// architecture the host can run. winget hides upgrades whose latest
8319/// version publishes no compatible installer.
8320fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
8321    if manifest.installers.is_empty() {
8322        return true;
8323    }
8324    let allowed = preferred_architectures(current_architecture());
8325    manifest.installers.iter().any(|installer| {
8326        let arch = installer.architecture.as_deref().unwrap_or("neutral");
8327        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
8328    })
8329}
8330
8331fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
8332    match system_architecture {
8333        "arm64" => &["arm64", "neutral", "x64", "x86"],
8334        "x64" => &["x64", "neutral", "x86"],
8335        "x86" => &["x86", "neutral"],
8336        _ => &["neutral"],
8337    }
8338}
8339
8340fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
8341    match (installer_locale, requested_locale) {
8342        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
8343        (Some(installer), Some(requested))
8344            if installer
8345                .split('-')
8346                .next()
8347                .zip(requested.split('-').next())
8348                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
8349        {
8350            2
8351        }
8352        (None, Some(_)) => 1,
8353        (Some(_), None) => 1,
8354        (None, None) => 0,
8355        _ => -1,
8356    }
8357}
8358
8359fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
8360    match requested {
8361        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
8362        None => true,
8363    }
8364}
8365
8366#[cfg(windows)]
8367fn current_architecture() -> &'static str {
8368    use std::sync::OnceLock;
8369    static CELL: OnceLock<&'static str> = OnceLock::new();
8370    CELL.get_or_init(|| {
8371        // IsWow64Process2's `native_machine` is the OS architecture even
8372        // when the process is running under emulation (x64 binary on
8373        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
8374        // mis-report the host arch in that case.
8375        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
8376        let mut process_machine: u16 = 0;
8377        let mut native_machine: u16 = 0;
8378        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
8379        // both out-pointers reference local stack u16s that outlive the call.
8380        let handle = unsafe { GetCurrentProcess() };
8381        // SAFETY: handle is valid per above; pointers are valid.
8382        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
8383        if ok != 0 {
8384            match native_machine {
8385                0xAA64 => return "arm64",
8386                0x8664 => return "x64",
8387                0x014C => return "x86",
8388                _ => {}
8389            }
8390        }
8391        compiled_architecture()
8392    })
8393}
8394
8395#[cfg(not(windows))]
8396fn current_architecture() -> &'static str {
8397    compiled_architecture()
8398}
8399
8400fn compiled_architecture() -> &'static str {
8401    match std::env::consts::ARCH {
8402        "x86_64" => "x64",
8403        "x86" => "x86",
8404        "aarch64" => "arm64",
8405        _ => "neutral",
8406    }
8407}
8408
8409fn current_platform() -> Option<&'static str> {
8410    if cfg!(windows) { Some("Windows.Desktop") } else { None }
8411}
8412
8413#[cfg(windows)]
8414fn current_os_version() -> Option<String> {
8415    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8416    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
8417    let build: String = key.get_value("CurrentBuildNumber").ok()?;
8418    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
8419    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
8420    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
8421    Some(format!("{major}.{minor}.{build}.{ubr}"))
8422}
8423
8424#[cfg(not(windows))]
8425fn current_os_version() -> Option<String> {
8426    None
8427}
8428
8429fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
8430    let mut parsed = Vec::new();
8431    for component in version.split('.') {
8432        if component.is_empty() {
8433            continue;
8434        }
8435        parsed.push(component.parse().ok()?);
8436    }
8437
8438    if parsed.is_empty() { None } else { Some(parsed) }
8439}
8440
8441fn select_v1_version(
8442    versions: &[V1VersionRow],
8443    requested_version: Option<&str>,
8444    requested_channel: Option<&str>,
8445    package_id: &str,
8446    source_name: &str,
8447) -> Result<V1VersionRow> {
8448    let mut ordered = versions.to_vec();
8449    ordered.sort_by(|left, right| {
8450        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8451    });
8452
8453    if let Some(version) = requested_version {
8454        let channel = requested_channel.unwrap_or_default();
8455        return ordered
8456            .iter()
8457            .find(|candidate| {
8458                candidate.version.eq_ignore_ascii_case(version)
8459                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8460            })
8461            .cloned()
8462            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8463    }
8464
8465    if let Some(channel) = requested_channel {
8466        return ordered
8467            .iter()
8468            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8469            .cloned()
8470            .ok_or_else(|| {
8471                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8472            });
8473    }
8474
8475    ordered
8476        .into_iter()
8477        .next()
8478        .ok_or_else(|| anyhow!("package had no versions"))
8479}
8480
8481fn select_v2_version(
8482    versions: &[PackageVersionDataEntry],
8483    requested_version: Option<&str>,
8484    requested_channel: Option<&str>,
8485    package_id: &str,
8486    source_name: &str,
8487) -> Result<PackageVersionDataEntry> {
8488    let mut ordered = versions.to_vec();
8489    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
8490
8491    if let Some(channel) = requested_channel {
8492        bail!(
8493            "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."
8494        );
8495    }
8496
8497    if let Some(version) = requested_version {
8498        return ordered
8499            .iter()
8500            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
8501            .cloned()
8502            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8503    }
8504
8505    ordered
8506        .into_iter()
8507        .next()
8508        .ok_or_else(|| anyhow!("package had no V2 versions"))
8509}
8510
8511fn missing_package_version(
8512    package_id: &str,
8513    source_name: &str,
8514    requested_version: &str,
8515    requested_channel: Option<&str>,
8516) -> anyhow::Error {
8517    anyhow!(MissingPackageVersionError {
8518        package_id: package_id.to_owned(),
8519        source_name: source_name.to_owned(),
8520        requested_version: requested_version.to_owned(),
8521        requested_channel: requested_channel.map(str::to_owned),
8522    })
8523}
8524
8525fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
8526    if let Some(refresh_error) = refresh.error.as_deref() {
8527        let missing_error = error.to_string();
8528        return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
8529    }
8530
8531    Err(error)
8532}
8533
8534fn select_rest_version<'a>(
8535    versions: &'a [VersionKey],
8536    requested_version: Option<&str>,
8537    requested_channel: Option<&str>,
8538    package_id: &str,
8539    source_name: &str,
8540) -> Result<&'a VersionKey> {
8541    if let Some(version) = requested_version {
8542        let channel = requested_channel.unwrap_or_default();
8543        return versions
8544            .iter()
8545            .find(|candidate| {
8546                candidate.version.eq_ignore_ascii_case(version)
8547                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8548            })
8549            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8550    }
8551
8552    if let Some(channel) = requested_channel {
8553        return versions
8554            .iter()
8555            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8556            .ok_or_else(|| {
8557                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8558            });
8559    }
8560
8561    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
8562}
8563
8564fn sort_versions_desc(versions: &mut [VersionKey]) {
8565    versions.sort_by(|left, right| {
8566        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8567    });
8568}
8569
8570fn compare_version_and_channel(
8571    left_version: &str,
8572    left_channel: &str,
8573    right_version: &str,
8574    right_channel: &str,
8575) -> Ordering {
8576    compare_version(left_version, right_version).then_with(|| {
8577        left_channel
8578            .to_ascii_lowercase()
8579            .cmp(&right_channel.to_ascii_lowercase())
8580    })
8581}
8582
8583fn compare_version(left: &str, right: &str) -> Ordering {
8584    let left_less_than = left.trim().strip_prefix('<').map(str::trim);
8585    let right_less_than = right.trim().strip_prefix('<').map(str::trim);
8586    match (left_less_than, right_less_than) {
8587        (Some(left_value), Some(right_value)) => return compare_version(left_value, right_value),
8588        (Some(left_value), None) if compare_version(left_value, right) == Ordering::Equal => return Ordering::Less,
8589        (None, Some(right_value)) if compare_version(left, right_value) == Ordering::Equal => return Ordering::Greater,
8590        _ => {}
8591    }
8592
8593    let left_parts = tokenize_version(left);
8594    let right_parts = tokenize_version(right);
8595    let max_len = left_parts.len().max(right_parts.len());
8596
8597    for index in 0..max_len {
8598        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
8599        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
8600        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
8601
8602        let ordering = if let Some((left_number, right_number)) = numeric {
8603            left_number.cmp(&right_number)
8604        } else {
8605            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
8606        };
8607
8608        if ordering != Ordering::Equal {
8609            return ordering;
8610        }
8611    }
8612
8613    Ordering::Equal
8614}
8615
8616fn tokenize_version(value: &str) -> Vec<String> {
8617    let mut parts = Vec::new();
8618    let mut current = String::new();
8619    let mut current_is_digit = None;
8620
8621    for ch in value.chars() {
8622        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
8623            if !current.is_empty() {
8624                parts.push(std::mem::take(&mut current));
8625            }
8626            current_is_digit = None;
8627            continue;
8628        }
8629
8630        let is_digit = ch.is_ascii_digit();
8631        match current_is_digit {
8632            Some(previous) if previous != is_digit => {
8633                parts.push(std::mem::take(&mut current));
8634                current.push(ch);
8635                current_is_digit = Some(is_digit);
8636            }
8637            Some(_) => current.push(ch),
8638            None => {
8639                current.push(ch);
8640                current_is_digit = Some(is_digit);
8641            }
8642        }
8643    }
8644
8645    if !current.is_empty() {
8646        parts.push(current);
8647    }
8648
8649    parts
8650}
8651
8652fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
8653    if !hash_matches(Some(expected_hash), bytes) {
8654        bail!("downloaded file hash did not match expected SHA256");
8655    }
8656    Ok(())
8657}
8658
8659#[cfg(test)]
8660fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
8661    let Some(expected_hash) = expected_hash else {
8662        return Ok(());
8663    };
8664    if ignore_security_hash {
8665        return Ok(());
8666    }
8667
8668    if !hash_matches(Some(expected_hash), bytes) {
8669        let actual = sha256_hex(bytes);
8670        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
8671    }
8672    Ok(())
8673}
8674
8675fn verify_installer_hash_hex(expected_hash: Option<&str>, actual_hash: &str, ignore_security_hash: bool) -> Result<()> {
8676    let Some(expected_hash) = expected_hash else {
8677        return Ok(());
8678    };
8679    if ignore_security_hash {
8680        return Ok(());
8681    }
8682
8683    if !actual_hash.eq_ignore_ascii_case(expected_hash) {
8684        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual_hash}");
8685    }
8686    Ok(())
8687}
8688
8689const INSTALLER_DOWNLOAD_ATTEMPTS: usize = 3;
8690const INSTALLER_DOWNLOAD_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(30);
8691const INSTALLER_DOWNLOAD_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(60);
8692const INSTALLER_DOWNLOAD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30 * 60);
8693const INSTALLER_DOWNLOAD_METADATA_INTERVAL: u64 = 4 * 1024 * 1024;
8694
8695#[derive(Debug, Clone, Serialize, Deserialize)]
8696struct InstallerDownloadMetadata {
8697    url: String,
8698    etag: Option<String>,
8699    last_modified: Option<String>,
8700    total_length: Option<u64>,
8701    offset: u64,
8702}
8703
8704#[derive(Debug)]
8705struct DownloadAttempt {
8706    total_length: Option<u64>,
8707}
8708
8709fn download_installer_to_file(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8710    let runtime = tokio::runtime::Builder::new_current_thread()
8711        .enable_all()
8712        .build()
8713        .context("failed to create installer download runtime")?;
8714    runtime.block_on(download_installer_to_file_async(user_agent, url, dest))
8715}
8716
8717async fn download_installer_to_file_async(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8718    let part_path = sidecar_path(dest, "part");
8719    let metadata_path = sidecar_path(dest, "part.json");
8720    let started_at = Instant::now();
8721    let mut last_error = None;
8722
8723    for _attempt in 1..=INSTALLER_DOWNLOAD_ATTEMPTS {
8724        if started_at.elapsed() >= INSTALLER_DOWNLOAD_TOTAL_TIMEOUT {
8725            break;
8726        }
8727
8728        let remaining = INSTALLER_DOWNLOAD_TOTAL_TIMEOUT
8729            .checked_sub(started_at.elapsed())
8730            .unwrap_or_else(|| StdDuration::from_secs(1));
8731        match try_download_installer_to_part(user_agent, url, &part_path, &metadata_path, remaining).await {
8732            Ok(()) => {
8733                let actual_hash = sha256_file_hex(&part_path)?;
8734                replace_file(&part_path, dest)?;
8735                let _ = fs::remove_file(&metadata_path);
8736                return Ok(actual_hash);
8737            }
8738            Err(error) => {
8739                last_error = Some(error);
8740            }
8741        }
8742    }
8743
8744    Err(last_error.unwrap_or_else(|| anyhow!("installer download timed out")))
8745}
8746
8747async fn try_download_installer_to_part(
8748    user_agent: &str,
8749    url: &str,
8750    part_path: &Path,
8751    metadata_path: &Path,
8752    remaining: StdDuration,
8753) -> Result<()> {
8754    let metadata = read_installer_download_metadata(metadata_path, part_path, url)?;
8755    let mut offset = metadata.as_ref().map_or(0, |metadata| metadata.offset);
8756    let mut request = reqwest::Client::builder()
8757        .user_agent(user_agent)
8758        .connect_timeout(INSTALLER_DOWNLOAD_CONNECT_TIMEOUT)
8759        .timeout(remaining)
8760        .build()
8761        .context("failed to build installer download client")?
8762        .get(url)
8763        .header(ACCEPT_ENCODING, "identity");
8764
8765    if offset > 0 {
8766        request = request.header(RANGE, format!("bytes={offset}-"));
8767        if let Some(if_range) = metadata.as_ref().and_then(installer_download_if_range) {
8768            request = request.header(IF_RANGE, if_range);
8769        }
8770    }
8771
8772    let mut response = request.send().await.context("failed to download installer")?;
8773    let headers = response.headers().clone();
8774    let status = response.status();
8775    let attempt = if status == StatusCode::PARTIAL_CONTENT && offset > 0 {
8776        parse_content_range(headers.get(CONTENT_RANGE), offset)?
8777    } else if status == StatusCode::OK {
8778        offset = 0;
8779        DownloadAttempt {
8780            total_length: content_length(&headers),
8781        }
8782    } else if status == StatusCode::RANGE_NOT_SATISFIABLE {
8783        let _ = fs::remove_file(part_path);
8784        let _ = fs::remove_file(metadata_path);
8785        bail!("installer partial download could not be resumed; retrying from the beginning");
8786    } else if status.is_success() {
8787        bail!("unexpected installer download HTTP status {status}");
8788    } else {
8789        bail!("Download failed: HTTP {status}");
8790    };
8791
8792    if offset == 0 {
8793        let _ = fs::remove_file(part_path);
8794    }
8795
8796    let mut file = if offset == 0 {
8797        fs::File::create(part_path).with_context(|| format!("failed to create {}", part_path.display()))?
8798    } else {
8799        OpenOptions::new()
8800            .append(true)
8801            .open(part_path)
8802            .with_context(|| format!("failed to open {}", part_path.display()))?
8803    };
8804
8805    let mut metadata = InstallerDownloadMetadata {
8806        url: url.to_owned(),
8807        etag: strong_etag(&headers),
8808        last_modified: header_string(&headers, LAST_MODIFIED),
8809        total_length: attempt.total_length,
8810        offset,
8811    };
8812    write_installer_download_metadata(metadata_path, &metadata)?;
8813
8814    let mut next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8815    while let Some(chunk) = tokio::time::timeout(INSTALLER_DOWNLOAD_IDLE_TIMEOUT, response.chunk())
8816        .await
8817        .context("installer download stalled while waiting for data")?
8818        .context("failed to read installer response body")?
8819    {
8820        file.write_all(&chunk)
8821            .with_context(|| format!("failed to write {}", part_path.display()))?;
8822        offset += u64::try_from(chunk.len()).expect("chunk length fits in u64");
8823
8824        if offset >= next_metadata_offset {
8825            file.flush()
8826                .with_context(|| format!("failed to flush {}", part_path.display()))?;
8827            metadata.offset = offset;
8828            write_installer_download_metadata(metadata_path, &metadata)?;
8829            next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8830        }
8831    }
8832
8833    file.flush()
8834        .with_context(|| format!("failed to flush {}", part_path.display()))?;
8835    file.sync_all()
8836        .with_context(|| format!("failed to sync {}", part_path.display()))?;
8837
8838    if let Some(total_length) = attempt.total_length
8839        && offset != total_length
8840    {
8841        bail!("installer download ended early: received {offset} of {total_length} bytes");
8842    }
8843
8844    metadata.offset = offset;
8845    metadata.total_length = attempt.total_length;
8846    write_installer_download_metadata(metadata_path, &metadata)?;
8847    Ok(())
8848}
8849
8850fn read_installer_download_metadata(
8851    metadata_path: &Path,
8852    part_path: &Path,
8853    url: &str,
8854) -> Result<Option<InstallerDownloadMetadata>> {
8855    let Some(mut metadata) = fs::read(metadata_path)
8856        .ok()
8857        .and_then(|bytes| serde_json::from_slice::<InstallerDownloadMetadata>(&bytes).ok())
8858    else {
8859        let _ = fs::remove_file(part_path);
8860        let _ = fs::remove_file(metadata_path);
8861        return Ok(None);
8862    };
8863
8864    if metadata.url != url || metadata.offset == 0 {
8865        let _ = fs::remove_file(part_path);
8866        let _ = fs::remove_file(metadata_path);
8867        return Ok(None);
8868    }
8869
8870    let part_len = fs::metadata(part_path).map(|metadata| metadata.len()).unwrap_or(0);
8871    metadata.offset = metadata.offset.min(part_len);
8872    if metadata.offset == 0 {
8873        let _ = fs::remove_file(part_path);
8874        let _ = fs::remove_file(metadata_path);
8875        return Ok(None);
8876    }
8877
8878    OpenOptions::new()
8879        .write(true)
8880        .open(part_path)
8881        .with_context(|| format!("failed to open {}", part_path.display()))?
8882        .set_len(metadata.offset)
8883        .with_context(|| format!("failed to truncate {}", part_path.display()))?;
8884
8885    Ok(Some(metadata))
8886}
8887
8888fn write_installer_download_metadata(path: &Path, metadata: &InstallerDownloadMetadata) -> Result<()> {
8889    let bytes = serde_json::to_vec(metadata).context("failed to serialize installer download metadata")?;
8890    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
8891}
8892
8893fn installer_download_if_range(metadata: &InstallerDownloadMetadata) -> Option<String> {
8894    metadata
8895        .etag
8896        .as_ref()
8897        .filter(|etag| !etag.starts_with("W/"))
8898        .cloned()
8899        .or_else(|| metadata.last_modified.clone())
8900}
8901
8902fn parse_content_range(value: Option<&HeaderValue>, expected_start: u64) -> Result<DownloadAttempt> {
8903    let value = value
8904        .and_then(|value| value.to_str().ok())
8905        .ok_or_else(|| anyhow!("partial installer response missing Content-Range"))?;
8906    let range = value
8907        .strip_prefix("bytes ")
8908        .ok_or_else(|| anyhow!("unsupported Content-Range value: {value}"))?;
8909    let (span, total) = range
8910        .split_once('/')
8911        .ok_or_else(|| anyhow!("invalid Content-Range value: {value}"))?;
8912    let (start, _) = span
8913        .split_once('-')
8914        .ok_or_else(|| anyhow!("invalid Content-Range byte span: {value}"))?;
8915    let start = start
8916        .parse::<u64>()
8917        .with_context(|| format!("invalid Content-Range start: {value}"))?;
8918    if start != expected_start {
8919        bail!("partial installer response started at {start}, expected {expected_start}");
8920    }
8921    let total_length = if total == "*" {
8922        None
8923    } else {
8924        Some(
8925            total
8926                .parse::<u64>()
8927                .with_context(|| format!("invalid Content-Range total: {value}"))?,
8928        )
8929    };
8930    Ok(DownloadAttempt { total_length })
8931}
8932
8933fn content_length(headers: &HeaderMap) -> Option<u64> {
8934    headers
8935        .get(CONTENT_LENGTH)
8936        .and_then(|value| value.to_str().ok())
8937        .and_then(|value| value.parse::<u64>().ok())
8938}
8939
8940fn strong_etag(headers: &HeaderMap) -> Option<String> {
8941    header_string(headers, ETAG).filter(|value| !value.starts_with("W/"))
8942}
8943
8944fn header_string(headers: &HeaderMap, name: reqwest::header::HeaderName) -> Option<String> {
8945    headers
8946        .get(name)
8947        .and_then(|value| value.to_str().ok())
8948        .map(str::to_owned)
8949}
8950
8951fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
8952    let mut value = path.as_os_str().to_os_string();
8953    value.push(".");
8954    value.push(suffix);
8955    PathBuf::from(value)
8956}
8957
8958fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
8959    expected_hash
8960        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
8961        .unwrap_or(true)
8962}
8963
8964fn sha256_hex(bytes: &[u8]) -> String {
8965    let digest = Sha256::digest(bytes);
8966    sha256_digest_hex(digest)
8967}
8968
8969fn sha256_file_hex(path: &Path) -> Result<String> {
8970    let mut file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
8971    let mut hasher = Sha256::new();
8972    let mut buffer = [0u8; 128 * 1024];
8973    loop {
8974        let read = file
8975            .read(&mut buffer)
8976            .with_context(|| format!("failed to read {}", path.display()))?;
8977        if read == 0 {
8978            break;
8979        }
8980        hasher.update(&buffer[..read]);
8981    }
8982    Ok(sha256_digest_hex(hasher.finalize()))
8983}
8984
8985fn sha256_digest_hex(digest: impl AsRef<[u8]>) -> String {
8986    let digest = digest.as_ref();
8987    let mut output = String::with_capacity(digest.len() * 2);
8988    for byte in digest {
8989        output.push_str(&format!("{byte:02X}"));
8990    }
8991    output
8992}
8993
8994fn pins_db_path(app_root: &Path) -> PathBuf {
8995    if uses_packaged_layout(app_root) {
8996        app_root.join("pinning.db")
8997    } else {
8998        app_root.join("pins.db")
8999    }
9000}
9001
9002fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
9003    let mut has_current_column = false;
9004    let mut has_legacy_column = false;
9005
9006    for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
9007        match column_name.as_str() {
9008            "type" => has_current_column = true,
9009            "pin_type" => has_legacy_column = true,
9010            _ => {}
9011        }
9012    }
9013
9014    Ok(if has_current_column {
9015        Some("type")
9016    } else if has_legacy_column {
9017        Some("pin_type")
9018    } else {
9019        None
9020    })
9021}
9022
9023fn decode_pin_type(pin_type_int: i64) -> PinType {
9024    match pin_type_int {
9025        4 => PinType::Blocking,
9026        3 => PinType::Gating,
9027        _ => PinType::Pinning,
9028    }
9029}
9030
9031#[cfg(windows)]
9032fn dispatch_installer(
9033    installer_path: &Path,
9034    installer_type: &str,
9035    request: &InstallRequest,
9036    manifest: &Manifest,
9037    installer: &Installer,
9038) -> Result<i32> {
9039    match installer_type {
9040        "msi" | "wix" if should_run_msi_direct(request, installer) => run_msi_direct(
9041            installer_path,
9042            &installer_direct_msi_arguments(installer_type, request, manifest, installer_path, installer),
9043        ),
9044        "msi" | "wix" => shell_execute_installer(
9045            Path::new("msiexec.exe"),
9046            &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9047            should_elevate_msi_shell_execute(installer),
9048        ),
9049        "msix" | "appx" => {
9050            use std::process::Command;
9051
9052            let mut cmd = Command::new("powershell");
9053            cmd.arg("-NoProfile")
9054                .arg("-Command")
9055                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
9056            let status = cmd.status().context("failed to run Add-AppxPackage")?;
9057            Ok(status.code().unwrap_or(-1))
9058        }
9059        "zip" => {
9060            // A `zip` at the top-level installer position is treated as portable when
9061            // `NestedInstallerType=portable` is specified — that's the shape every
9062            // portable in the winget catalog uses (e.g. BurntSushi.ripgrep.MSVC).
9063            // Anything else still falls through to the historical extract-to-Programs
9064            // behavior, which isn't really correct but at least doesn't regress.
9065            if installer
9066                .nested_installer_type
9067                .as_deref()
9068                .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9069            {
9070                return install_portable(installer_path, request, manifest, installer);
9071            }
9072            let target = dirs::data_local_dir()
9073                .unwrap_or_else(|| PathBuf::from("."))
9074                .join("Programs");
9075            fs::create_dir_all(&target)?;
9076            let file = fs::File::open(installer_path)?;
9077            let mut archive = ZipArchive::new(file)?;
9078            archive.extract(&target)?;
9079            Ok(0)
9080        }
9081        "portable" => install_portable(installer_path, request, manifest, installer),
9082        // exe, inno, nullsoft, burn, etc. are launched through ShellExecute so
9083        // Windows can honor installer manifests and legacy elevation detection.
9084        _ => shell_execute_installer(
9085            installer_path,
9086            &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9087            false,
9088        ),
9089    }
9090}
9091
9092#[cfg(windows)]
9093fn should_run_msi_direct(request: &InstallRequest, installer: &Installer) -> bool {
9094    request.mode == InstallerMode::Silent && !should_elevate_msi_shell_execute(installer)
9095}
9096
9097#[cfg(windows)]
9098fn should_elevate_msi_shell_execute(installer: &Installer) -> bool {
9099    installer
9100        .scope
9101        .as_deref()
9102        .is_some_and(|scope| scope.eq_ignore_ascii_case("machine"))
9103}
9104
9105#[cfg(windows)]
9106fn shell_execute_installer(installer_path: &Path, args: &[String], run_as: bool) -> Result<i32> {
9107    use std::ffi::OsStr;
9108
9109    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, WAIT_OBJECT_0};
9110    use windows_sys::Win32::System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject};
9111    use windows_sys::Win32::UI::Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW};
9112    use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
9113
9114    let file = wide_null(installer_path.as_os_str());
9115    let parameters = wide_null(OsStr::new(&join_windows_arguments(args)));
9116    let verb = run_as.then(|| wide_null(OsStr::new("runas")));
9117
9118    // SAFETY: SHELLEXECUTEINFOW is a plain Win32 data structure; zero initialization
9119    // is the documented starting point before setting cbSize and the fields we use.
9120    let mut exec_info = unsafe { std::mem::zeroed::<SHELLEXECUTEINFOW>() };
9121    exec_info.cbSize =
9122        u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).expect("SHELLEXECUTEINFOW size fits in u32");
9123    exec_info.fMask = SEE_MASK_NOCLOSEPROCESS;
9124    exec_info.lpFile = file.as_ptr();
9125    exec_info.lpParameters = parameters.as_ptr();
9126    if let Some(verb) = verb.as_ref() {
9127        exec_info.lpVerb = verb.as_ptr();
9128    }
9129    exec_info.nShow = SW_SHOW;
9130
9131    // SAFETY: exec_info points to a valid SHELLEXECUTEINFOW whose string pointers
9132    // reference live, nul-terminated UTF-16 buffers for the duration of the call.
9133    if unsafe { ShellExecuteExW(&mut exec_info) } == 0 {
9134        bail!(
9135            "failed to launch installer through ShellExecute (Win32 error {})",
9136            // SAFETY: GetLastError reads the calling thread's last-error value.
9137            unsafe { GetLastError() }
9138        );
9139    }
9140
9141    if exec_info.hProcess.is_null() {
9142        bail!("ShellExecute did not return an installer process handle");
9143    }
9144
9145    // SAFETY: ShellExecuteExW returned a non-null process handle with
9146    // SEE_MASK_NOCLOSEPROCESS, so waiting on it is valid.
9147    let wait_result = unsafe { WaitForSingleObject(exec_info.hProcess, INFINITE) };
9148    if wait_result != WAIT_OBJECT_0 {
9149        // SAFETY: hProcess is the owned process handle returned by ShellExecuteExW.
9150        let _ = unsafe { CloseHandle(exec_info.hProcess) };
9151        bail!("failed while waiting for installer process (Win32 wait result {wait_result})");
9152    }
9153
9154    let mut exit_code = 0u32;
9155    // SAFETY: hProcess is still open and valid, and exit_code points to writable memory.
9156    let got_exit_code = unsafe { GetExitCodeProcess(exec_info.hProcess, &mut exit_code) };
9157    // SAFETY: hProcess is the owned process handle returned by ShellExecuteExW.
9158    let _ = unsafe { CloseHandle(exec_info.hProcess) };
9159    if got_exit_code == 0 {
9160        // SAFETY: GetLastError reads the calling thread's last-error value.
9161        let error = unsafe { GetLastError() };
9162        bail!("failed to retrieve installer exit code (Win32 error {error})");
9163    }
9164
9165    Ok(exit_code.cast_signed())
9166}
9167
9168#[cfg(windows)]
9169fn wide_null(value: &std::ffi::OsStr) -> Vec<u16> {
9170    value.encode_wide().chain(std::iter::once(0)).collect()
9171}
9172
9173#[cfg(windows)]
9174fn run_msi_direct(installer_path: &Path, args: &[String]) -> Result<i32> {
9175    use std::ffi::OsStr;
9176    use std::ptr::{null, null_mut};
9177
9178    use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
9179        INSTALLUILEVEL_NONE, INSTALLUILEVEL_UACONLY, MsiEnableLogW, MsiInstallProductW, MsiSetInternalUI,
9180    };
9181
9182    let parsed = parse_msi_direct_arguments(args)?;
9183    let package_path = wide_null(installer_path.as_os_str());
9184    let properties = wide_null(OsStr::new(&parsed.properties));
9185    let property_ptr = if parsed.properties.is_empty() {
9186        null()
9187    } else {
9188        properties.as_ptr()
9189    };
9190    let log_path = parsed.log_path.as_ref().map(|path| wide_null(OsStr::new(path)));
9191
9192    if let Some(path) = log_path.as_ref() {
9193        // SAFETY: path is a live, nul-terminated UTF-16 buffer; mode and attributes
9194        // are parsed from MSI logging switches.
9195        let result = unsafe { MsiEnableLogW(parsed.log_mode, path.as_ptr(), parsed.log_attributes) };
9196        if result != 0 {
9197            bail!("failed to enable MSI logging (Win32 error {result})");
9198        }
9199    } else {
9200        // SAFETY: Passing a null file with mode 0 disables MSI logging.
9201        let _ = unsafe { MsiEnableLogW(0, null(), 0) };
9202    }
9203
9204    let ui_level = if parsed.ui_level == INSTALLUILEVEL_NONE as u32 {
9205        (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY) as u32
9206    } else {
9207        parsed.ui_level
9208    };
9209    // SAFETY: ui_level is composed from documented INSTALLUILEVEL flags; null HWND
9210    // matches the MsiSetInternalUI contract when no owner window is supplied.
9211    unsafe { MsiSetInternalUI(ui_level.cast_signed(), null_mut()) };
9212    // SAFETY: package_path and property_ptr are valid, nul-terminated UTF-16
9213    // strings (or null for no properties) for the duration of the call.
9214    let result = unsafe { MsiInstallProductW(package_path.as_ptr(), property_ptr) };
9215    Ok(result.cast_signed())
9216}
9217
9218#[cfg(any(windows, test))]
9219#[derive(Debug, PartialEq, Eq)]
9220struct MsiDirectArguments {
9221    ui_level: u32,
9222    properties: String,
9223    log_mode: u32,
9224    log_path: Option<String>,
9225    log_attributes: u32,
9226}
9227
9228#[cfg(any(windows, test))]
9229fn parse_msi_direct_arguments(args: &[String]) -> Result<MsiDirectArguments> {
9230    const INSTALLUILEVEL_DEFAULT: u32 = 1;
9231    const INSTALLUILEVEL_NONE: u32 = 2;
9232    const INSTALLUILEVEL_BASIC: u32 = 3;
9233    const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9234    const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9235    const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9236    const LOGMODE_DEFAULT: u32 = 1 | 2 | 4 | 16 | 128 | 256 | 512;
9237    const LOGMODE_ALL: u32 = LOGMODE_DEFAULT | 8 | 1024 | 2048;
9238    const LOGMODE_VERBOSE: u32 = 4096;
9239    const LOGMODE_EXTRADEBUG: u32 = 8192;
9240    const LOGATTR_APPEND: u32 = 1;
9241    const LOGATTR_FLUSH: u32 = 2;
9242
9243    let mut parsed = MsiDirectArguments {
9244        ui_level: INSTALLUILEVEL_DEFAULT,
9245        properties: String::new(),
9246        log_mode: 0,
9247        log_path: None,
9248        log_attributes: 0,
9249    };
9250
9251    let mut index = 0;
9252    while index < args.len() {
9253        let token = &args[index];
9254        if is_msi_switch(token) {
9255            let option = &token[1..];
9256            if option.eq_ignore_ascii_case("quiet") {
9257                parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9258            } else if option.eq_ignore_ascii_case("passive") {
9259                parsed.ui_level = INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL;
9260                append_msi_property(&mut parsed.properties, "REBOOTPROMPT=S");
9261            } else if option.eq_ignore_ascii_case("norestart") {
9262                append_msi_property(&mut parsed.properties, "REBOOT=ReallySuppress");
9263            } else if option.eq_ignore_ascii_case("forcerestart") {
9264                append_msi_property(&mut parsed.properties, "REBOOT=Force");
9265            } else if option.eq_ignore_ascii_case("promptrestart") {
9266                append_msi_property(&mut parsed.properties, "REBOOTPROMPT=\"\"");
9267            } else if option.eq_ignore_ascii_case("log") {
9268                index += 1;
9269                let path = args.get(index).ok_or_else(|| anyhow!("MSI /log requires a log path"))?;
9270                parsed.log_mode = LOGMODE_ALL;
9271                parsed.log_path = Some(path.clone());
9272            } else if option.len() == 1 && option.eq_ignore_ascii_case("q") {
9273                parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9274            } else if option.starts_with(['q', 'Q']) {
9275                parsed.ui_level = parse_msi_ui_level(&option[1..])?;
9276            } else if option.starts_with(['l', 'L']) {
9277                index += 1;
9278                let path = args
9279                    .get(index)
9280                    .ok_or_else(|| anyhow!("MSI logging switch requires a log path"))?;
9281                let (mode, attributes) = parse_msi_log_mode(
9282                    &option[1..],
9283                    LOGMODE_DEFAULT,
9284                    LOGMODE_ALL,
9285                    LOGMODE_VERBOSE,
9286                    LOGMODE_EXTRADEBUG,
9287                    LOGATTR_APPEND,
9288                    LOGATTR_FLUSH,
9289                )?;
9290                parsed.log_mode = mode;
9291                parsed.log_attributes = attributes;
9292                parsed.log_path = Some(path.clone());
9293            } else {
9294                bail!("unsupported MSI switch for direct MSI execution: {token}");
9295            }
9296        } else {
9297            append_msi_property(&mut parsed.properties, &format_msi_property(token)?);
9298        }
9299        index += 1;
9300    }
9301
9302    Ok(parsed)
9303}
9304
9305#[cfg(any(windows, test))]
9306fn is_msi_switch(token: &str) -> bool {
9307    token.starts_with('/') || token.starts_with('-')
9308}
9309
9310#[cfg(any(windows, test))]
9311fn append_msi_property(properties: &mut String, property: &str) {
9312    if !properties.is_empty() {
9313        properties.push(' ');
9314    }
9315    properties.push_str(property);
9316}
9317
9318#[cfg(any(windows, test))]
9319fn format_msi_property(token: &str) -> Result<String> {
9320    let Some((name, value)) = token.split_once('=') else {
9321        bail!("invalid MSI property argument: {token}");
9322    };
9323    if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
9324        bail!("invalid MSI property name: {name}");
9325    }
9326    if value.contains(char::is_whitespace) && !(value.starts_with('"') && value.ends_with('"')) {
9327        Ok(format!("{name}=\"{}\"", value.replace('"', "\"\"")))
9328    } else {
9329        Ok(token.to_owned())
9330    }
9331}
9332
9333#[cfg(any(windows, test))]
9334fn parse_msi_ui_level(modifier: &str) -> Result<u32> {
9335    const INSTALLUILEVEL_NONE: u32 = 2;
9336    const INSTALLUILEVEL_BASIC: u32 = 3;
9337    const INSTALLUILEVEL_REDUCED: u32 = 4;
9338    const INSTALLUILEVEL_FULL: u32 = 5;
9339    const INSTALLUILEVEL_ENDDIALOG: u32 = 0x80;
9340    const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9341    const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9342    const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9343
9344    let modifier = if modifier.is_empty() { "n" } else { modifier };
9345    let mut chars = modifier.chars();
9346    let Some(first) = chars.next() else {
9347        return Ok(INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY);
9348    };
9349    let mut ui_level = match first.to_ascii_lowercase() {
9350        'f' => INSTALLUILEVEL_FULL,
9351        'r' => INSTALLUILEVEL_REDUCED,
9352        'b' => INSTALLUILEVEL_BASIC,
9353        'n' => INSTALLUILEVEL_NONE,
9354        '+' => INSTALLUILEVEL_NONE | INSTALLUILEVEL_ENDDIALOG,
9355        _ => bail!("invalid MSI /q modifier: {modifier}"),
9356    };
9357
9358    for ch in chars {
9359        match ch {
9360            '+' => ui_level |= INSTALLUILEVEL_ENDDIALOG,
9361            '-' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_PROGRESSONLY,
9362            '!' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_HIDECANCEL,
9363            '-' | '!' => bail!("MSI /q modifier {ch} is only valid with basic UI"),
9364            _ => bail!("invalid MSI /q modifier: {modifier}"),
9365        }
9366    }
9367
9368    if ui_level & 0xF == INSTALLUILEVEL_NONE {
9369        ui_level |= INSTALLUILEVEL_UACONLY;
9370    }
9371    Ok(ui_level)
9372}
9373
9374#[cfg(any(windows, test))]
9375#[allow(clippy::too_many_arguments)]
9376fn parse_msi_log_mode(
9377    modifier: &str,
9378    default_mode: u32,
9379    all_mode: u32,
9380    verbose: u32,
9381    extra_debug: u32,
9382    append: u32,
9383    flush: u32,
9384) -> Result<(u32, u32)> {
9385    let mut mode = 0;
9386    let mut attributes = 0;
9387    for ch in modifier.chars() {
9388        match ch.to_ascii_lowercase() {
9389            '*' => mode |= all_mode,
9390            'm' => mode |= 1,
9391            'e' => mode |= 2,
9392            'w' => mode |= 4,
9393            'u' => mode |= 8,
9394            'i' => mode |= 16,
9395            'o' => mode |= 128,
9396            'a' => mode |= 256,
9397            'r' => mode |= 512,
9398            'p' => mode |= 1024,
9399            'c' => mode |= 2048,
9400            'v' => mode |= verbose,
9401            'x' => mode |= extra_debug,
9402            '+' => attributes |= append,
9403            '!' => attributes |= flush,
9404            _ => bail!("invalid MSI /l modifier: {modifier}"),
9405        }
9406    }
9407    if mode == 0 {
9408        mode = default_mode;
9409    }
9410    Ok((mode, attributes))
9411}
9412
9413#[cfg(not(windows))]
9414fn dispatch_installer(
9415    _installer_path: &Path,
9416    _installer_type: &str,
9417    _request: &InstallRequest,
9418    _manifest: &Manifest,
9419    _installer: &Installer,
9420) -> Result<i32> {
9421    bail!("Installing packages is only supported on Windows")
9422}
9423
9424#[cfg(test)]
9425fn installer_command_arguments(
9426    installer_type: &str,
9427    request: &InstallRequest,
9428    _manifest: &Manifest,
9429    installer_path: &Path,
9430    installer: &Installer,
9431) -> Vec<String> {
9432    installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9433}
9434
9435#[cfg(any(windows, test))]
9436fn installer_direct_msi_arguments(
9437    installer_type: &str,
9438    request: &InstallRequest,
9439    _manifest: &Manifest,
9440    installer_path: &Path,
9441    installer: &Installer,
9442) -> Vec<String> {
9443    installer_command_arguments_with_options(installer_type, request, installer_path, installer, false)
9444}
9445
9446#[cfg(any(windows, test))]
9447fn installer_shell_execute_arguments(
9448    installer_type: &str,
9449    request: &InstallRequest,
9450    _manifest: &Manifest,
9451    installer_path: &Path,
9452    installer: &Installer,
9453) -> Vec<String> {
9454    installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9455}
9456
9457#[cfg(any(windows, test))]
9458fn installer_command_arguments_with_options(
9459    installer_type: &str,
9460    request: &InstallRequest,
9461    installer_path: &Path,
9462    installer: &Installer,
9463    include_msi_install_target: bool,
9464) -> Vec<String> {
9465    if let Some(override_args) = request.override_args.as_deref() {
9466        return split_installer_switches(override_args);
9467    }
9468
9469    let mut args = Vec::new();
9470    match installer_type {
9471        "msi" | "wix" if include_msi_install_target => {
9472            args.push("/i".to_owned());
9473            args.push(installer_path.display().to_string());
9474        }
9475        "msix" | "appx" | "zip" => return Vec::new(),
9476        _ => {}
9477    }
9478
9479    let configured = match request.mode {
9480        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
9481        InstallerMode::SilentWithProgress => installer
9482            .switches
9483            .silent_with_progress
9484            .as_deref()
9485            .or(installer.switches.silent.as_deref()),
9486        InstallerMode::Silent => installer
9487            .switches
9488            .silent
9489            .as_deref()
9490            .or(installer.switches.silent_with_progress.as_deref()),
9491    };
9492
9493    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
9494        args.extend(split_installer_switches(value));
9495    } else {
9496        args.extend(default_installer_arguments(installer_type, request.mode));
9497    }
9498
9499    append_switches(
9500        &mut args,
9501        resolve_template_switch(
9502            installer.switches.log.as_deref(),
9503            default_log_switch(installer_type),
9504            request.log_path.as_ref().map(|path| path.display().to_string()),
9505            "<LOGPATH>",
9506        ),
9507    );
9508    append_switches(&mut args, installer.switches.custom.clone());
9509    append_switches(&mut args, request.custom.clone());
9510    append_switches(
9511        &mut args,
9512        resolve_template_switch(
9513            installer.switches.install_location.as_deref(),
9514            default_install_location_switch(installer_type),
9515            request.install_location.clone(),
9516            "<INSTALLPATH>",
9517        ),
9518    );
9519    args
9520}
9521
9522#[cfg(any(windows, test))]
9523fn join_windows_arguments(args: &[String]) -> String {
9524    args.iter()
9525        .map(|arg| quote_windows_argument(arg))
9526        .collect::<Vec<_>>()
9527        .join(" ")
9528}
9529
9530#[cfg(any(windows, test))]
9531fn quote_windows_argument(arg: &str) -> String {
9532    if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') {
9533        return arg.to_owned();
9534    }
9535
9536    let mut quoted = String::from("\"");
9537    let mut backslashes = 0usize;
9538    for ch in arg.chars() {
9539        match ch {
9540            '\\' => backslashes += 1,
9541            '"' => {
9542                quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
9543                quoted.push('"');
9544                backslashes = 0;
9545            }
9546            _ => {
9547                quoted.push_str(&"\\".repeat(backslashes));
9548                backslashes = 0;
9549                quoted.push(ch);
9550            }
9551        }
9552    }
9553    quoted.push_str(&"\\".repeat(backslashes * 2));
9554    quoted.push('"');
9555    quoted
9556}
9557
9558#[cfg(any(windows, test))]
9559fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
9560    match mode {
9561        InstallerMode::Interactive => Vec::new(),
9562        InstallerMode::SilentWithProgress => match installer_type {
9563            "inno" => vec![
9564                "/SP-".to_owned(),
9565                "/SILENT".to_owned(),
9566                "/SUPPRESSMSGBOXES".to_owned(),
9567                "/NORESTART".to_owned(),
9568            ],
9569            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
9570            "nullsoft" | "nsis" => vec!["/S".to_owned()],
9571            _ => vec!["/SILENT".to_owned()],
9572        },
9573        InstallerMode::Silent => match installer_type {
9574            "inno" => vec![
9575                "/SP-".to_owned(),
9576                "/VERYSILENT".to_owned(),
9577                "/SUPPRESSMSGBOXES".to_owned(),
9578                "/NORESTART".to_owned(),
9579            ],
9580            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
9581            "nullsoft" | "nsis" => vec!["/S".to_owned()],
9582            _ => vec!["/S".to_owned()],
9583        },
9584    }
9585}
9586
9587#[cfg(any(windows, test))]
9588fn default_log_switch(installer_type: &str) -> Option<&'static str> {
9589    match installer_type {
9590        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
9591        "inno" => Some("/LOG=\"<LOGPATH>\""),
9592        _ => None,
9593    }
9594}
9595
9596#[cfg(any(windows, test))]
9597fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
9598    match installer_type {
9599        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
9600        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
9601        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
9602        _ => None,
9603    }
9604}
9605
9606#[cfg(any(windows, test))]
9607fn resolve_template_switch(
9608    manifest_value: Option<&str>,
9609    fallback: Option<&str>,
9610    replacement: Option<String>,
9611    token: &str,
9612) -> Option<String> {
9613    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
9614    let replacement = replacement?;
9615    Some(template.replace(token, &replacement))
9616}
9617
9618#[cfg(any(windows, test))]
9619fn append_switches(args: &mut Vec<String>, value: Option<String>) {
9620    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
9621        args.extend(split_installer_switches(&value));
9622    }
9623}
9624
9625#[cfg(any(windows, test))]
9626fn split_installer_switches(value: &str) -> Vec<String> {
9627    let mut args = Vec::new();
9628    let mut current = String::new();
9629    let mut in_quotes = false;
9630
9631    for ch in value.chars() {
9632        match ch {
9633            '"' => in_quotes = !in_quotes,
9634            c if c.is_whitespace() && !in_quotes => {
9635                if !current.is_empty() {
9636                    args.push(std::mem::take(&mut current));
9637                }
9638            }
9639            _ => current.push(ch),
9640        }
9641    }
9642
9643    if !current.is_empty() {
9644        args.push(current);
9645    }
9646
9647    args
9648}
9649
9650#[cfg(windows)]
9651fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
9652    if request.purge && request.preserve {
9653        bail!("--purge and --preserve cannot be used together.");
9654    }
9655
9656    if installed
9657        .installer_category
9658        .as_deref()
9659        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9660    {
9661        return uninstall_portable(installed, request);
9662    }
9663
9664    if (request.purge || request.preserve) && !request.force {
9665        bail!("--purge and --preserve are currently only supported for portable packages.");
9666    }
9667
9668    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
9669        return Ok(exit_code);
9670    }
9671
9672    if let Some(exit_code) = try_uninstall_msix(installed)? {
9673        return Ok(exit_code);
9674    }
9675
9676    bail!(
9677        "No uninstall command found for installed package '{}' ({})",
9678        installed.name,
9679        installed.local_id
9680    );
9681}
9682
9683#[cfg(windows)]
9684fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9685    use std::os::windows::process::CommandExt;
9686    use std::process::Command;
9687
9688    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
9689    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9690
9691    let arp_paths = [
9692        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
9693        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
9694    ];
9695
9696    for root in [&hklm, &hkcu] {
9697        for arp_path in &arp_paths {
9698            if let Ok(key) = root.open_subkey(arp_path) {
9699                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
9700                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
9701                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
9702                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
9703                        if !registry_entry_matches_installed_package(
9704                            &subkey_name,
9705                            &display_name,
9706                            product_code.as_deref(),
9707                            installed,
9708                        ) {
9709                            continue;
9710                        }
9711
9712                        if let Some(exit_code) =
9713                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
9714                        {
9715                            return Ok(Some(exit_code));
9716                        }
9717
9718                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
9719                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
9720                            subkey.get_value("UninstallString").ok()
9721                        } else {
9722                            quiet_uninstall_cmd
9723                                .clone()
9724                                .or_else(|| subkey.get_value("UninstallString").ok())
9725                        }
9726                        .context("No uninstall command found in registry")?;
9727
9728                        if is_winget_uninstall_command(&uninstall_cmd)
9729                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
9730                        {
9731                            return Ok(Some(exit_code));
9732                        }
9733
9734                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
9735                        let uninstall_line = build_uninstall_command_with_mode(
9736                            &uninstall_cmd,
9737                            request.mode,
9738                            quiet_uninstall_cmd.is_some(),
9739                            log_path.as_deref(),
9740                        );
9741                        // `UninstallString` is a complete command line and may already contain
9742                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
9743                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
9744                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
9745                        // rejects with "is not recognized as an internal or external command".
9746                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
9747                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
9748                        // automatic quoting so we can feed cmd the literal command line.
9749                        let mut cmd = Command::new("cmd");
9750                        cmd.raw_arg("/S /C");
9751                        cmd.raw_arg(format!("\"{uninstall_line}\""));
9752                        let status = cmd.status().context("failed to run uninstaller")?;
9753                        return Ok(Some(status.code().unwrap_or(-1)));
9754                    }
9755                }
9756            }
9757        }
9758    }
9759
9760    Ok(None)
9761}
9762
9763#[cfg(windows)]
9764fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
9765    use std::process::Command;
9766
9767    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
9768        return Ok(None);
9769    }
9770
9771    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
9772    let mut cmd = Command::new("powershell");
9773    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
9774        package_full_name,
9775        &installed.package_family_names,
9776    ));
9777    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
9778    Ok(Some(status.code().unwrap_or(-1)))
9779}
9780
9781#[cfg(windows)]
9782fn arp_subkey_name(local_id: &str) -> Option<&str> {
9783    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
9784}
9785
9786#[cfg(windows)]
9787fn registry_entry_matches_installed_package(
9788    subkey_name: &str,
9789    display_name: &str,
9790    product_code: Option<&str>,
9791    installed: &ListMatch,
9792) -> bool {
9793    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
9794        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
9795    {
9796        return true;
9797    }
9798
9799    if installed.product_codes.iter().any(|code| {
9800        code.eq_ignore_ascii_case(subkey_name)
9801            || product_code
9802                .map(|value| code.eq_ignore_ascii_case(value))
9803                .unwrap_or(false)
9804    }) {
9805        return true;
9806    }
9807
9808    display_name.eq_ignore_ascii_case(&installed.name)
9809}
9810
9811#[cfg(all(windows, test))]
9812fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
9813    build_uninstall_command_with_mode(
9814        uninstall_cmd,
9815        if silent {
9816            InstallerMode::Silent
9817        } else {
9818            InstallerMode::Interactive
9819        },
9820        has_quiet_command,
9821        None,
9822    )
9823}
9824
9825#[cfg(windows)]
9826fn build_uninstall_command_with_mode(
9827    uninstall_cmd: &str,
9828    mode: InstallerMode,
9829    has_quiet_command: bool,
9830    log_path: Option<&str>,
9831) -> String {
9832    let uninstall_cmd = log_path
9833        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
9834        .unwrap_or_else(|| uninstall_cmd.to_owned());
9835    if mode == InstallerMode::Interactive || has_quiet_command {
9836        return uninstall_cmd;
9837    }
9838
9839    let lower = uninstall_cmd.to_ascii_lowercase();
9840    if is_winget_uninstall_command_lower(&lower) {
9841        return uninstall_cmd;
9842    }
9843    if lower.contains("/quiet")
9844        || lower.contains("/passive")
9845        || lower.contains("/verysilent")
9846        || lower.contains("/silent")
9847        || lower.contains(" /s")
9848    {
9849        return uninstall_cmd;
9850    }
9851
9852    format!("{uninstall_cmd} /S")
9853}
9854
9855#[cfg(windows)]
9856fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9857    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
9858        return Ok(None);
9859    };
9860
9861    let exit_code = run_winget_uninstall(&args)?;
9862    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
9863        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
9864            return Ok(Some(exit_code));
9865        };
9866
9867        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
9868    }
9869
9870    Ok(Some(exit_code))
9871}
9872
9873#[cfg(windows)]
9874fn run_winget_uninstall(args: &[String]) -> Result<i32> {
9875    use std::process::Command;
9876
9877    let status = Command::new("winget")
9878        .args(args)
9879        .status()
9880        .context("failed to run delegated winget uninstall")?;
9881    Ok(status.code().unwrap_or(-1))
9882}
9883
9884#[cfg(windows)]
9885fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
9886    build_winget_uninstall_arguments_with_scope(installed, request, true)
9887}
9888
9889#[cfg(windows)]
9890fn build_winget_uninstall_arguments_with_scope(
9891    installed: &ListMatch,
9892    request: &UninstallRequest,
9893    include_scope: bool,
9894) -> Option<Vec<String>> {
9895    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
9896        return None;
9897    }
9898
9899    let mut args = vec![
9900        "uninstall".to_owned(),
9901        "--id".to_owned(),
9902        installed.id.clone(),
9903        "--exact".to_owned(),
9904        "--disable-interactivity".to_owned(),
9905    ];
9906
9907    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
9908        args.push("--source".to_owned());
9909        args.push(source_name.to_owned());
9910    }
9911
9912    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
9913        args.push("--scope".to_owned());
9914        args.push(scope.to_owned());
9915    }
9916
9917    if request.mode == InstallerMode::Silent {
9918        args.push("--silent".to_owned());
9919    }
9920
9921    Some(args)
9922}
9923
9924#[cfg(windows)]
9925fn is_winget_uninstall_command(command: &str) -> bool {
9926    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
9927}
9928
9929#[cfg(windows)]
9930fn is_winget_uninstall_command_lower(command: &str) -> bool {
9931    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
9932}
9933
9934#[cfg(windows)]
9935fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
9936    let full_name_literal = package_full_name
9937        .map(|value| format!("'{}'", value.replace('\'', "''")))
9938        .unwrap_or_else(|| "$null".to_owned());
9939    let family_names_literal = if package_family_names.is_empty() {
9940        "@()".to_owned()
9941    } else {
9942        format!(
9943            "@({})",
9944            package_family_names
9945                .iter()
9946                .map(|value| format!("'{}'", value.replace('\'', "''")))
9947                .collect::<Vec<_>>()
9948                .join(", ")
9949        )
9950    };
9951
9952    format!(
9953        "$fullName = {full_name_literal}; \
9954         $familyNames = {family_names_literal}; \
9955         $targets = Get-AppxPackage | Where-Object {{ \
9956         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
9957         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
9958         if (-not $targets) {{ exit 1 }}; \
9959         $targets | Remove-AppxPackage"
9960    )
9961}
9962
9963#[cfg(windows)]
9964fn try_run_msi_uninstall(
9965    installed: &ListMatch,
9966    subkey_name: &str,
9967    product_code: Option<&str>,
9968    request: &UninstallRequest,
9969) -> Result<Option<i32>> {
9970    use std::process::Command;
9971
9972    let uninstall_code = installed
9973        .product_codes
9974        .iter()
9975        .map(String::as_str)
9976        .chain(product_code)
9977        .chain(Some(subkey_name))
9978        .find(|value| is_product_code_like(value));
9979
9980    let Some(uninstall_code) = uninstall_code else {
9981        return Ok(None);
9982    };
9983
9984    let mut cmd = Command::new("msiexec");
9985    cmd.arg("/x").arg(uninstall_code);
9986    match request.mode {
9987        InstallerMode::Interactive => {}
9988        InstallerMode::SilentWithProgress => {
9989            cmd.arg("/passive").arg("/norestart");
9990        }
9991        InstallerMode::Silent => {
9992            cmd.arg("/quiet").arg("/norestart");
9993        }
9994    }
9995    if let Some(log_path) = &request.log_path {
9996        cmd.arg("/log").arg(log_path);
9997    }
9998
9999    let status = cmd.status().context("failed to run msiexec uninstall")?;
10000    Ok(Some(status.code().unwrap_or(-1)))
10001}
10002
10003#[cfg(windows)]
10004fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
10005    // Read PortableSymlinkFullPath from the ARP entry *before* we touch the
10006    // registry, so we can remove winget-created shims in Links\ if any. Without
10007    // this, a portable that winget originally installed (with a
10008    // PortableCommandAlias set in its manifest) would leave a dangling symlink
10009    // in %LOCALAPPDATA%\Microsoft\WinGet\Links\ once pinget uninstalls it.
10010    let known_symlink = read_portable_symlink_full_path(installed);
10011
10012    let Some(location) = installed.install_location.as_deref() else {
10013        if request.force {
10014            try_remove_portable_symlinks(installed, &known_symlink, None);
10015            try_remove_portable_registry_entry(installed);
10016            return Ok(0);
10017        }
10018        bail!(
10019            "Portable package '{}' does not expose an install location.",
10020            installed.name
10021        );
10022    };
10023
10024    if request.preserve {
10025        return Ok(0);
10026    }
10027
10028    let path = Path::new(location);
10029    let removed = if path.is_dir() {
10030        fs::remove_dir_all(path)?;
10031        true
10032    } else if path.is_file() {
10033        fs::remove_file(path)?;
10034        true
10035    } else {
10036        false
10037    };
10038
10039    if removed || request.force {
10040        // Once the files are gone (or the user forced past missing files), drop
10041        // the symlink shim and ARP entry too so the package fully disappears.
10042        try_remove_portable_symlinks(installed, &known_symlink, Some(path));
10043        try_remove_portable_registry_entry(installed);
10044        return Ok(0);
10045    }
10046
10047    bail!("Portable package location not found: {location}")
10048}
10049
10050/// Reads `PortableSymlinkFullPath` from the ARP subkey backing `installed`
10051/// when present. This value is recorded for portable installs that created a
10052/// `Links\<alias>.exe` symlink, including pinget-installed entries when
10053/// `install_portable` created that link. Entries without a created/recorded
10054/// portable symlink return `None`.
10055#[cfg(windows)]
10056fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
10057    const ARP_PREFIX: &str = r"ARP\";
10058    if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
10059        let mut parts = rest.splitn(3, '\\');
10060        let scope = parts.next().unwrap_or("");
10061        let _arch = parts.next();
10062        let subkey_name = parts.next().unwrap_or("");
10063        if subkey_name.is_empty() {
10064            return None;
10065        }
10066        let hive = if scope.eq_ignore_ascii_case("User") {
10067            RegKey::predef(HKEY_CURRENT_USER)
10068        } else {
10069            RegKey::predef(HKEY_LOCAL_MACHINE)
10070        };
10071        let subkey = hive
10072            .open_subkey(format!(
10073                r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
10074            ))
10075            .ok()?;
10076        return read_reg_string(&subkey, "PortableSymlinkFullPath");
10077    }
10078    None
10079}
10080
10081/// Removes the symlink shims winget would have created for this portable.
10082/// Tries (1) the exact path from `PortableSymlinkFullPath` if known, and
10083/// (2) any symlink in `%LOCALAPPDATA%\Microsoft\WinGet\Links\` whose target
10084/// resolves under `install_location` (defensive sweep for shims winget made
10085/// but didn't surface in the registry). Best-effort: failures are swallowed.
10086#[cfg(windows)]
10087fn try_remove_portable_symlinks(
10088    _installed: &ListMatch,
10089    known_symlink: &Option<String>,
10090    install_location: Option<&Path>,
10091) {
10092    if let Some(path) = known_symlink.as_deref() {
10093        let _ = fs::remove_file(path);
10094    }
10095
10096    // Defensive sweep: walk Links\ and remove shims whose stored target was
10097    // inside (or equal to) install_location. By this point install_location is
10098    // already deleted, so we compare the symlink's stored target string (which
10099    // the filesystem retains even after the target itself is gone).
10100    let Some(install_dir) = install_location else { return };
10101
10102    let Some(local) = dirs::data_local_dir() else { return };
10103    let links_root = local.join("Microsoft").join("WinGet").join("Links");
10104    let Ok(entries) = fs::read_dir(&links_root) else { return };
10105    for entry in entries.flatten() {
10106        let path = entry.path();
10107        let Ok(target) = fs::read_link(&path) else { continue };
10108        if path_starts_with_case_insensitive(&target, install_dir) {
10109            let _ = fs::remove_file(&path);
10110        }
10111    }
10112}
10113
10114/// Case-insensitive, component-aware path-prefix check for Windows. A raw
10115/// `str::starts_with` on the path strings would let `C:\Foo` falsely match
10116/// `C:\Foobar`; iterating path components avoids that. Used by the Links\
10117/// sweep so an uninstall of one portable can't accidentally delete shims that
10118/// belong to a different portable whose install dir happens to share a
10119/// string-prefix.
10120#[cfg(windows)]
10121fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
10122    let lower_components = |p: &Path| -> Vec<String> {
10123        p.components()
10124            .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
10125            .collect()
10126    };
10127    let parent_components = lower_components(parent);
10128    let child_components = lower_components(child);
10129    if parent_components.is_empty() || parent_components.len() > child_components.len() {
10130        return false;
10131    }
10132    parent_components
10133        .iter()
10134        .zip(child_components.iter())
10135        .all(|(a, b)| a == b)
10136}
10137
10138/// Best-effort removal of the HKCU/HKLM ARP subkey backing a portable entry.
10139/// We match on local_id (`ARP\<scope>\<arch>\<subkey>`) when present so we
10140/// delete the exact key the list view surfaced; otherwise we walk the standard
10141/// Uninstall path and match on WinGetPackageIdentifier. Failures are logged via
10142/// the returned warning chain in callers — we never bubble a registry-cleanup
10143/// failure as a hard error because the file deletion already succeeded.
10144#[cfg(windows)]
10145fn try_remove_portable_registry_entry(installed: &ListMatch) {
10146    // Collect (is_hkcu, subkey_path) pairs so we can re-open hives at delete
10147    // time instead of holding clones of RegKey (which doesn't implement Clone).
10148    let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
10149        // local_id format is `ARP\<scope>\<arch>\<subkey>` — pick the right hive/path.
10150        let mut parts = rest.splitn(3, '\\');
10151        let scope = parts.next().unwrap_or("");
10152        let _arch = parts.next();
10153        let subkey_name = parts.next().unwrap_or("");
10154        if subkey_name.is_empty() {
10155            return;
10156        }
10157        let is_hkcu = scope.eq_ignore_ascii_case("User");
10158        vec![(
10159            is_hkcu,
10160            format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
10161        )]
10162    } else {
10163        // Fall back to scanning by WinGetPackageIdentifier.
10164        let mut found = Vec::new();
10165        for (is_hkcu, hive) in [
10166            (true, RegKey::predef(HKEY_CURRENT_USER)),
10167            (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
10168        ] {
10169            let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
10170                continue;
10171            };
10172            for name in uninstall.enum_keys().flatten() {
10173                let Ok(subkey) = uninstall.open_subkey(&name) else {
10174                    continue;
10175                };
10176                if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
10177                    found.push((
10178                        is_hkcu,
10179                        format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
10180                    ));
10181                }
10182            }
10183        }
10184        found
10185    };
10186
10187    for (is_hkcu, path) in candidates {
10188        let hive = if is_hkcu {
10189            RegKey::predef(HKEY_CURRENT_USER)
10190        } else {
10191            RegKey::predef(HKEY_LOCAL_MACHINE)
10192        };
10193        let _ = hive.delete_subkey_all(&path);
10194    }
10195}
10196
10197#[cfg(windows)]
10198fn is_product_code_like(value: &str) -> bool {
10199    value.starts_with('{') && value.ends_with('}')
10200}
10201
10202#[cfg(not(windows))]
10203fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
10204    bail!("Uninstalling packages is only supported on Windows")
10205}
10206
10207/// Installs (or upgrades) a portable WinGet package.
10208///
10209/// Handles both `InstallerType: portable` (single binary download) and
10210/// `InstallerType: zip` with `NestedInstallerType: portable` (zip containing
10211/// the binary at a relative path), which is the shape every portable in the
10212/// community winget catalog uses today.
10213///
10214/// Behavior matches winget's portable workflow closely enough that the resulting
10215/// HKCU ARP entry is recognized by `winget list` and by pinget's own list view:
10216/// - Resolves the target install directory in this priority order:
10217///   1. `request.install_location` if provided (passed for upgrades)
10218///   2. Existing `InstallLocation` from the registry ARP entry (so upgrades
10219///      with no explicit location still preserve the user's custom path)
10220///   3. WinGet's default user portable root, `%LOCALAPPDATA%\Microsoft\WinGet
10221///      \Packages\<PackageId>_<SourceId>`
10222/// - If an existing pinget-owned install lives in that directory
10223///   (`InstallDirectoryCreated=1`), cleans its contents before extracting the
10224///   new version so leftover files from older versions don't pile up.
10225/// - Extracts the zip (or copies the standalone binary), then writes the ARP
10226///   registry entry that `winget list` and pinget's `collect_uninstall_view`
10227///   both read from.
10228#[cfg(windows)]
10229fn install_portable(
10230    installer_path: &Path,
10231    request: &InstallRequest,
10232    manifest: &Manifest,
10233    installer: &Installer,
10234) -> Result<i32> {
10235    let source_identifier = portable_source_identifier(&request.query);
10236    let existing_entry = read_existing_portable_entry(&manifest.id);
10237    // Reuse the existing subkey name on upgrade so we don't orphan the prior
10238    // entry by writing to a different one.
10239    let subkey_name = existing_entry
10240        .as_ref()
10241        .map(|entry| entry.subkey_name.clone())
10242        .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
10243
10244    let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
10245
10246    // If we created this directory on a previous install, wipe it clean so the
10247    // new version doesn't co-exist with leftovers from the previous one. We do
10248    // not touch a directory we don't own (InstallDirectoryCreated != 1), to
10249    // avoid stomping user data.
10250    let prev_dir_created = existing_entry
10251        .as_ref()
10252        .and_then(|entry| entry.install_directory_created)
10253        == Some(1);
10254    let dir_existed = target_dir.is_dir();
10255    if prev_dir_created && dir_existed {
10256        clean_directory_contents(&target_dir).with_context(|| {
10257            format!(
10258                "failed to clean existing portable install directory: {}",
10259                target_dir.display()
10260            )
10261        })?;
10262    }
10263
10264    fs::create_dir_all(&target_dir)
10265        .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
10266    // The "we created it" flag stays sticky across upgrades — if a previous
10267    // install marked it created, it still counts as created by us even when
10268    // the dir already existed this round.
10269    let install_directory_created = !dir_existed || prev_dir_created;
10270
10271    let installer_type = installer.installer_type.as_deref().unwrap_or("");
10272    let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
10273        let file = fs::File::open(installer_path)
10274            .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
10275        let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
10276        archive
10277            .extract(&target_dir)
10278            .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
10279        // For nested-portable zips the binary lives at the RelativeFilePath the
10280        // manifest declares. We record the first one as PortableTargetFullPath so
10281        // winget's portable uninstall workflow can identify which file to remove.
10282        installer
10283            .nested_installer_files
10284            .first()
10285            .map(|file| target_dir.join(&file.relative_file_path))
10286            .unwrap_or_else(|| target_dir.clone())
10287    } else {
10288        // Standalone portable: copy the downloaded file into target_dir using
10289        // its original filename. winget would optionally rename to the
10290        // PortableCommandAlias; pinget keeps the original name for now.
10291        let basename = installer_path
10292            .file_name()
10293            .ok_or_else(|| anyhow!("portable installer has no filename"))?;
10294        let copied = target_dir.join(basename);
10295        fs::copy(installer_path, &copied)
10296            .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
10297        copied
10298    };
10299
10300    // Create the Links\<alias>.exe shim so users can invoke the portable from
10301    // any shell, matching winget's portable workflow. Failures are non-fatal —
10302    // when the user lacks SeCreateSymbolicLink (no Developer Mode, not admin)
10303    // we still leave the package installed at install_location.
10304    let alias = determine_portable_alias(installer);
10305    let symlink_full_path = match alias.as_deref() {
10306        Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
10307        _ => None,
10308    };
10309
10310    // Ensure %LOCALAPPDATA%\Microsoft\WinGet\Links is on user PATH so the
10311    // symlink we just created is resolvable from new shells. Tracks whether we
10312    // added it this round so uninstall can decide whether to take it back out.
10313    let added_to_path = if symlink_full_path.is_some() {
10314        try_add_links_dir_to_user_path()
10315    } else {
10316        false
10317    };
10318
10319    write_portable_arp_entry(&PortableArpEntry {
10320        subkey_name: &subkey_name,
10321        install_location: &target_dir,
10322        portable_target_full_path: &portable_target_full_path,
10323        portable_symlink_full_path: symlink_full_path.as_deref(),
10324        install_directory_created,
10325        added_to_path,
10326        source_identifier: &source_identifier,
10327        manifest,
10328    })?;
10329
10330    Ok(0)
10331}
10332
10333#[cfg(windows)]
10334struct PortableArpEntry<'a> {
10335    subkey_name: &'a str,
10336    install_location: &'a Path,
10337    portable_target_full_path: &'a Path,
10338    portable_symlink_full_path: Option<&'a Path>,
10339    install_directory_created: bool,
10340    added_to_path: bool,
10341    source_identifier: &'a str,
10342    manifest: &'a Manifest,
10343}
10344
10345/// Picks the portable command alias from the manifest. Mirrors winget's
10346/// resolution: nested-installer files first (used by zip+portable manifests),
10347/// then the top-level `Commands` field (used by standalone portable manifests).
10348/// Returns None when the manifest doesn't declare a command — the package
10349/// still installs, just without a Links\ shim.
10350#[cfg(windows)]
10351fn determine_portable_alias(installer: &Installer) -> Option<String> {
10352    installer
10353        .nested_installer_files
10354        .iter()
10355        .find_map(|file| file.portable_command_alias.clone())
10356        .or_else(|| installer.commands.first().cloned())
10357}
10358
10359#[cfg(windows)]
10360fn winget_links_dir() -> PathBuf {
10361    dirs::data_local_dir()
10362        .unwrap_or_else(|| PathBuf::from("."))
10363        .join("Microsoft")
10364        .join("WinGet")
10365        .join("Links")
10366}
10367
10368/// Creates (or replaces) `Links\<alias>.exe` as a file symlink pointing at the
10369/// portable's binary. Returns the link path on success. Returns None and logs
10370/// a warning if the user can't create symlinks (no Developer Mode, not admin)
10371/// so the install can still complete without the shim.
10372#[cfg(windows)]
10373fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
10374    use std::os::windows::fs::symlink_file;
10375    let links_dir = winget_links_dir();
10376    if fs::create_dir_all(&links_dir).is_err() {
10377        return None;
10378    }
10379    let link_path = links_dir.join(format!("{alias}.exe"));
10380
10381    // Replace any prior link/file at the path so upgrades repoint to the new
10382    // binary instead of leaving a stale symlink behind.
10383    if link_path.symlink_metadata().is_ok() {
10384        let _ = fs::remove_file(&link_path);
10385    }
10386
10387    // Symlink creation fails with ERROR_PRIVILEGE_NOT_HELD (1314) when the
10388    // process lacks SeCreateSymbolicLink (no Developer Mode, not admin). Treat
10389    // that — and any other I/O failure — as non-fatal: the package files are
10390    // already in place, just without a Links\ shim.
10391    symlink_file(target, &link_path).ok().map(|()| link_path)
10392}
10393
10394/// Adds `Links\` to the user's HKCU\Environment\Path if not already present,
10395/// then broadcasts WM_SETTINGCHANGE so explorer-spawned shells pick up the
10396/// new PATH without a logoff. Returns true if we actually appended PATH this
10397/// call — callers should write `InstallDirectoryAddedToPath` accordingly so a
10398/// follow-up uninstall doesn't take PATH back out for other portables.
10399#[cfg(windows)]
10400fn try_add_links_dir_to_user_path() -> bool {
10401    let links_dir = winget_links_dir();
10402    let links_str = links_dir.to_string_lossy().to_string();
10403    let normalized_links = links_str.to_ascii_lowercase();
10404
10405    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10406    let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
10407        return false;
10408    };
10409    let existing: String = env_key.get_value("Path").unwrap_or_default();
10410    let already_present = existing
10411        .split(';')
10412        .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
10413    if already_present {
10414        return false;
10415    }
10416
10417    let new_path = if existing.is_empty() {
10418        links_str
10419    } else if existing.ends_with(';') {
10420        format!("{existing}{links_str}")
10421    } else {
10422        format!("{existing};{links_str}")
10423    };
10424
10425    if env_key.set_value("Path", &new_path).is_err() {
10426        return false;
10427    }
10428
10429    broadcast_environment_change();
10430    true
10431}
10432
10433#[cfg(windows)]
10434fn broadcast_environment_change() {
10435    use std::ffi::OsStr;
10436    use std::os::windows::ffi::OsStrExt;
10437
10438    use windows_sys::Win32::UI::WindowsAndMessaging::{
10439        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
10440    };
10441
10442    let param: Vec<u16> = OsStr::new("Environment")
10443        .encode_wide()
10444        .chain(std::iter::once(0))
10445        .collect();
10446    // SAFETY: SendMessageTimeoutW only reads `param` for the duration of the
10447    // call (it copies the string before returning), and `result` is a stack
10448    // local we provide. No aliasing or lifetime issues.
10449    unsafe {
10450        let mut result: usize = 0;
10451        SendMessageTimeoutW(
10452            HWND_BROADCAST,
10453            WM_SETTINGCHANGE,
10454            0usize,
10455            param.as_ptr() as isize,
10456            SMTO_ABORTIFHUNG,
10457            5000,
10458            &mut result,
10459        );
10460    }
10461}
10462
10463/// Source identifier embedded in the ARP subkey name. We mirror winget for
10464/// the community repo so winget's UninstallString can resolve the subkey and
10465/// `winget list` still finds the package; everything else uses the source
10466/// name verbatim.
10467#[cfg(windows)]
10468fn portable_source_identifier(query: &PackageQuery) -> String {
10469    match query.source.as_deref() {
10470        Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10471        Some(name) if !name.is_empty() => name.to_owned(),
10472        _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10473    }
10474}
10475
10476#[cfg(windows)]
10477fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
10478    format!("{package_id}_{source_identifier}")
10479}
10480
10481#[cfg(windows)]
10482struct ExistingPortableEntry {
10483    install_location: Option<PathBuf>,
10484    install_directory_created: Option<u32>,
10485    subkey_name: String,
10486}
10487
10488#[cfg(windows)]
10489fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
10490    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10491    let uninstall = hkcu
10492        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
10493        .ok()?;
10494    for name in uninstall.enum_keys().flatten() {
10495        let Ok(subkey) = uninstall.open_subkey(&name) else {
10496            continue;
10497        };
10498        if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
10499            continue;
10500        }
10501        if !read_reg_string(&subkey, "WinGetInstallerType")
10502            .as_deref()
10503            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
10504        {
10505            continue;
10506        }
10507        return Some(ExistingPortableEntry {
10508            install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
10509            install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
10510            subkey_name: name,
10511        });
10512    }
10513    None
10514}
10515
10516#[cfg(windows)]
10517fn resolve_portable_install_location(
10518    request: &InstallRequest,
10519    existing: &Option<ExistingPortableEntry>,
10520    subkey_name: &str,
10521) -> PathBuf {
10522    resolve_portable_install_location_pure(
10523        request.install_location.as_deref(),
10524        existing
10525            .as_ref()
10526            .and_then(|e| e.install_location.as_deref())
10527            .and_then(Path::to_str),
10528        subkey_name,
10529        &default_user_portable_root(),
10530    )
10531}
10532
10533/// Pure resolution rule for the portable install directory. Priority:
10534/// `request_location` (CLI override) → `existing_location` (so upgrades
10535/// preserve a winget-installed location) → default user portable root joined
10536/// with the package's ARP subkey name.
10537#[cfg(windows)]
10538fn resolve_portable_install_location_pure(
10539    request_location: Option<&str>,
10540    existing_location: Option<&str>,
10541    subkey_name: &str,
10542    default_user_portable_root: &Path,
10543) -> PathBuf {
10544    if let Some(custom) = request_location
10545        && !custom.is_empty()
10546    {
10547        return PathBuf::from(custom);
10548    }
10549    if let Some(loc) = existing_location
10550        && !loc.is_empty()
10551    {
10552        return PathBuf::from(loc);
10553    }
10554    default_user_portable_root.join(subkey_name)
10555}
10556
10557#[cfg(windows)]
10558fn default_user_portable_root() -> PathBuf {
10559    dirs::data_local_dir()
10560        .unwrap_or_else(|| PathBuf::from("."))
10561        .join("Microsoft")
10562        .join("WinGet")
10563        .join("Packages")
10564}
10565
10566#[cfg(windows)]
10567fn clean_directory_contents(dir: &Path) -> Result<()> {
10568    if !dir.is_dir() {
10569        return Ok(());
10570    }
10571    for entry in fs::read_dir(dir)? {
10572        let entry = entry?;
10573        let path = entry.path();
10574        if path.is_dir() {
10575            fs::remove_dir_all(&path)?;
10576        } else {
10577            fs::remove_file(&path)?;
10578        }
10579    }
10580    Ok(())
10581}
10582
10583/// Writes the HKCU ARP entry winget would normally write for a portable
10584/// install. Only the subset that winget's portable list view and pinget's
10585/// `collect_uninstall_view` actually read.
10586#[cfg(windows)]
10587fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
10588    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10589    let uninstall_path = format!(
10590        r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
10591        entry.subkey_name
10592    );
10593    let (subkey, _) = hkcu
10594        .create_subkey(&uninstall_path)
10595        .context("failed to create portable ARP registry subkey")?;
10596
10597    let manifest = entry.manifest;
10598    let install_location_str = entry.install_location.to_string_lossy().to_string();
10599    subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
10600    subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
10601    subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
10602    subkey.set_value("InstallLocation", &install_location_str)?;
10603    subkey.set_value(
10604        "PortableTargetFullPath",
10605        &entry.portable_target_full_path.to_string_lossy().to_string(),
10606    )?;
10607    if let Some(symlink_path) = entry.portable_symlink_full_path {
10608        subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
10609    }
10610    subkey.set_value(
10611        "InstallDirectoryAddedToPath",
10612        &(if entry.added_to_path { 1u32 } else { 0u32 }),
10613    )?;
10614    subkey.set_value(
10615        "InstallDirectoryCreated",
10616        &(if entry.install_directory_created { 1u32 } else { 0u32 }),
10617    )?;
10618    subkey.set_value(
10619        "DisplayName",
10620        &if manifest.name.is_empty() {
10621            manifest.id.clone()
10622        } else {
10623            manifest.name.clone()
10624        },
10625    )?;
10626    subkey.set_value("DisplayVersion", &manifest.version)?;
10627    if let Some(publisher) = manifest.publisher.as_ref() {
10628        subkey.set_value("Publisher", publisher)?;
10629    }
10630    subkey.set_value(
10631        "UninstallString",
10632        &format!("winget uninstall --product-code {}", entry.subkey_name),
10633    )?;
10634    let today = Utc::now().format("%Y%m%d").to_string();
10635    subkey.set_value("InstallDate", &today)?;
10636    if let Some(url) = manifest.package_url.as_ref() {
10637        subkey.set_value("URLInfoAbout", url)?;
10638    }
10639
10640    Ok(())
10641}
10642
10643#[cfg(test)]
10644mod tests {
10645    use std::io::{Read as _, Write};
10646    use std::net::{SocketAddr, TcpListener, TcpStream};
10647    use std::path::{Path, PathBuf};
10648    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
10649    use std::thread::{self, JoinHandle};
10650
10651    use flate2::Compression;
10652    use flate2::write::DeflateEncoder;
10653    use zip::write::SimpleFileOptions;
10654
10655    use super::*;
10656
10657    fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
10658        Manifest {
10659            id: id.to_owned(),
10660            name: name.to_owned(),
10661            version: version.to_owned(),
10662            channel: String::new(),
10663            publisher: Some("Acme".to_owned()),
10664            description: Some("desc".to_owned()),
10665            moniker: None,
10666            package_url: None,
10667            publisher_url: None,
10668            publisher_support_url: None,
10669            license: None,
10670            license_url: None,
10671            privacy_url: None,
10672            author: None,
10673            copyright: None,
10674            copyright_url: None,
10675            release_notes: None,
10676            release_notes_url: None,
10677            tags: Vec::new(),
10678            agreements: Vec::new(),
10679            package_dependencies: Vec::new(),
10680            documentation: Vec::new(),
10681            icons: Vec::new(),
10682            installers: Vec::new(),
10683            require_explicit_upgrade: false,
10684        }
10685    }
10686
10687    #[test]
10688    fn derive_download_filename_matches_winget_pattern() {
10689        let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
10690        let installer = Installer {
10691            architecture: Some("arm64".to_owned()),
10692            installer_type: Some("exe".to_owned()),
10693            scope: Some("user".to_owned()),
10694            locale: Some("en-US".to_owned()),
10695            ..Installer::default()
10696        };
10697        assert_eq!(
10698            derive_download_manifest_filename(&manifest, &installer),
10699            "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
10700        );
10701    }
10702
10703    #[test]
10704    fn derive_download_filename_omits_optional_components() {
10705        let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
10706        let installer = Installer {
10707            architecture: Some("x64".to_owned()),
10708            installer_type: Some("exe".to_owned()),
10709            ..Installer::default()
10710        };
10711        // No scope, no locale → both omitted; architecture remains PascalCase.
10712        assert_eq!(
10713            derive_download_manifest_filename(&manifest, &installer),
10714            "7-Zip_26.01_X64_exe.yaml"
10715        );
10716    }
10717
10718    #[test]
10719    fn derive_download_filename_skips_unknown_version() {
10720        let manifest = sample_manifest("Test.Package", "Unknown", "Test");
10721        let installer = Installer {
10722            architecture: Some("neutral".to_owned()),
10723            installer_type: Some("msi".to_owned()),
10724            ..Installer::default()
10725        };
10726        assert_eq!(
10727            derive_download_manifest_filename(&manifest, &installer),
10728            "Test_Neutral_msi.yaml"
10729        );
10730    }
10731
10732    #[test]
10733    fn derive_download_filename_sanitizes_illegal_chars() {
10734        let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
10735        let installer = Installer {
10736            architecture: Some("x64".to_owned()),
10737            installer_type: Some("exe".to_owned()),
10738            ..Installer::default()
10739        };
10740        // `/` and `:` are illegal in Windows filenames; both must be replaced.
10741        let filename = derive_download_manifest_filename(&manifest, &installer);
10742        assert!(!filename.contains('/') && !filename.contains(':'));
10743        assert!(filename.starts_with("Some_Bad_Name_1.0"));
10744    }
10745
10746    #[test]
10747    fn build_download_manifest_yaml_overrides_identity_fields() {
10748        let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10749        // Documents contain a stale PackageVersion (e.g. empty) — the typed
10750        // manifest's corrected version must win.
10751        let documents = serde_json::json!({
10752            "PackageIdentifier": "stale-id",
10753            "PackageVersion": "",
10754            "PackageName": "Old Name",
10755            "Channel": "preview",
10756            "Installers": [
10757                { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
10758                { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
10759            ],
10760        });
10761        let installer = Installer {
10762            architecture: Some("x64".to_owned()),
10763            installer_type: Some("exe".to_owned()),
10764            url: Some("https://example.com/a".to_owned()),
10765            ..Installer::default()
10766        };
10767        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10768        assert!(yaml.contains("PackageIdentifier: App.Id"));
10769        assert!(yaml.contains("PackageVersion: 2.0.0"));
10770        assert!(yaml.contains("PackageName: App Name"));
10771        assert!(yaml.contains("ManifestType: merged"));
10772        // Channel had a value in docs but the typed manifest's channel is empty
10773        // → expect it stripped, since winget download manifests don't carry it.
10774        assert!(!yaml.contains("Channel: preview"));
10775        // Installers array narrows to the single selected installer.
10776        assert_eq!(yaml.matches("InstallerUrl").count(), 1);
10777        assert!(yaml.contains("https://example.com/a"));
10778        assert!(!yaml.contains("https://example.com/b"));
10779    }
10780
10781    #[test]
10782    fn build_download_manifest_yaml_preserves_typed_channel() {
10783        let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10784        manifest.channel = "beta".to_owned();
10785        let documents = serde_json::json!({
10786            "PackageIdentifier": "App.Id",
10787            "PackageVersion": "2.0.0",
10788            "Installers": [],
10789        });
10790        let installer = Installer::default();
10791        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10792        assert!(yaml.contains("Channel: beta"));
10793    }
10794
10795    fn temp_app_root(label: &str) -> PathBuf {
10796        static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
10797        std::env::temp_dir().join(format!(
10798            "pinget-rs-tests-{label}-{}-{}",
10799            std::process::id(),
10800            TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
10801        ))
10802    }
10803
10804    #[cfg(windows)]
10805    #[test]
10806    fn path_prefix_check_rejects_string_prefix_collisions() {
10807        // Regression: a naive str::starts_with would falsely consider
10808        // C:\Foobar a child of C:\Foo and let the Links\ sweep delete unrelated
10809        // symlinks. The component-aware check must reject these.
10810        assert!(!path_starts_with_case_insensitive(
10811            Path::new(r"C:\Foobar\bin.exe"),
10812            Path::new(r"C:\Foo"),
10813        ));
10814        assert!(!path_starts_with_case_insensitive(
10815            Path::new(r"C:\Foo\bin.exe.bak"),
10816            Path::new(r"C:\Foo\bin.exe"),
10817        ));
10818    }
10819
10820    #[cfg(windows)]
10821    #[test]
10822    fn path_prefix_check_accepts_real_children_and_self() {
10823        // Dir install: the symlink target lives inside the install dir.
10824        assert!(path_starts_with_case_insensitive(
10825            Path::new(r"C:\Foo\sub\bin.exe"),
10826            Path::new(r"C:\Foo"),
10827        ));
10828        // File install: the symlink points at the same file we removed.
10829        assert!(path_starts_with_case_insensitive(
10830            Path::new(r"C:\Foo\bin.exe"),
10831            Path::new(r"C:\Foo\bin.exe"),
10832        ));
10833    }
10834
10835    #[cfg(windows)]
10836    #[test]
10837    fn path_prefix_check_is_case_insensitive() {
10838        // Windows paths are case-insensitive — winget and pinget may write
10839        // them with different casing.
10840        assert!(path_starts_with_case_insensitive(
10841            Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
10842            Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
10843        ));
10844    }
10845
10846    #[cfg(windows)]
10847    #[test]
10848    fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
10849        let query = PackageQuery {
10850            source: Some("winget".to_owned()),
10851            ..PackageQuery::default()
10852        };
10853        assert_eq!(
10854            portable_source_identifier(&query),
10855            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10856        );
10857        // Case-insensitive — manifests/catalog may report differently.
10858        let query = PackageQuery {
10859            source: Some("WINGET".to_owned()),
10860            ..PackageQuery::default()
10861        };
10862        assert_eq!(
10863            portable_source_identifier(&query),
10864            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10865        );
10866    }
10867
10868    #[cfg(windows)]
10869    #[test]
10870    fn portable_source_identifier_passes_through_custom_sources() {
10871        let query = PackageQuery {
10872            source: Some("internal-feed".to_owned()),
10873            ..PackageQuery::default()
10874        };
10875        assert_eq!(portable_source_identifier(&query), "internal-feed");
10876    }
10877
10878    #[cfg(windows)]
10879    #[test]
10880    fn portable_source_identifier_defaults_for_missing_or_empty_source() {
10881        let query = PackageQuery {
10882            source: None,
10883            ..PackageQuery::default()
10884        };
10885        assert_eq!(
10886            portable_source_identifier(&query),
10887            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10888        );
10889        let query = PackageQuery {
10890            source: Some(String::new()),
10891            ..PackageQuery::default()
10892        };
10893        assert_eq!(
10894            portable_source_identifier(&query),
10895            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10896        );
10897    }
10898
10899    #[cfg(windows)]
10900    #[test]
10901    fn portable_subkey_name_matches_winget_format() {
10902        assert_eq!(
10903            portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
10904            "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
10905        );
10906    }
10907
10908    #[cfg(windows)]
10909    #[test]
10910    fn determine_portable_alias_prefers_nested_installer_alias() {
10911        let installer = Installer {
10912            nested_installer_files: vec![NestedInstallerFile {
10913                relative_file_path: "ripgrep/rg.exe".to_owned(),
10914                portable_command_alias: Some("rg".to_owned()),
10915            }],
10916            commands: vec!["other-name".to_owned()],
10917            ..Installer::default()
10918        };
10919        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
10920    }
10921
10922    #[cfg(windows)]
10923    #[test]
10924    fn determine_portable_alias_falls_back_to_commands() {
10925        let installer = Installer {
10926            // No nested files at all (standalone portable case).
10927            commands: vec!["nuget".to_owned()],
10928            ..Installer::default()
10929        };
10930        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
10931
10932        // Nested files exist but none declare PortableCommandAlias.
10933        let installer = Installer {
10934            nested_installer_files: vec![NestedInstallerFile {
10935                relative_file_path: "subdir/bin.exe".to_owned(),
10936                portable_command_alias: None,
10937            }],
10938            commands: vec!["bin".to_owned()],
10939            ..Installer::default()
10940        };
10941        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
10942    }
10943
10944    #[cfg(windows)]
10945    #[test]
10946    fn determine_portable_alias_returns_none_without_alias_or_commands() {
10947        let installer = Installer::default();
10948        assert!(determine_portable_alias(&installer).is_none());
10949    }
10950
10951    #[cfg(windows)]
10952    #[test]
10953    fn resolve_portable_install_location_prefers_request_override() {
10954        let result = resolve_portable_install_location_pure(
10955            Some(r"D:\Tools\rg"),
10956            Some(r"C:\Software\rg-test"),
10957            "Sub_Source",
10958            Path::new(r"C:\default"),
10959        );
10960        assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
10961    }
10962
10963    #[cfg(windows)]
10964    #[test]
10965    fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
10966        // The #4823 scenario: pinget upgrade with no explicit --location must
10967        // resolve to the install_location the prior install recorded, not the
10968        // default portable root.
10969        let result = resolve_portable_install_location_pure(
10970            None,
10971            Some(r"C:\Software\rg-test"),
10972            "Sub_Source",
10973            Path::new(r"C:\default"),
10974        );
10975        assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
10976    }
10977
10978    #[cfg(windows)]
10979    #[test]
10980    fn resolve_portable_install_location_falls_back_to_default_root() {
10981        let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
10982        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
10983    }
10984
10985    #[cfg(windows)]
10986    #[test]
10987    fn resolve_portable_install_location_treats_empty_strings_as_unset() {
10988        // Defensive: PackageQuery / registry reads can yield "" instead of
10989        // None for missing values.
10990        let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
10991        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
10992    }
10993
10994    #[cfg(windows)]
10995    #[test]
10996    fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
10997        let root = std::env::temp_dir().join(format!(
10998            "pinget-clean-test-{}",
10999            Utc::now().timestamp_nanos_opt().unwrap_or_default()
11000        ));
11001        fs::create_dir_all(&root).expect("create temp dir");
11002        fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
11003        fs::create_dir_all(root.join("nested")).expect("create nested dir");
11004        fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
11005
11006        clean_directory_contents(&root).expect("clean root contents");
11007
11008        assert!(root.is_dir(), "root dir itself must survive");
11009        assert_eq!(
11010            fs::read_dir(&root).expect("read root").count(),
11011            0,
11012            "root must be empty after clean"
11013        );
11014        let _ = fs::remove_dir_all(&root);
11015    }
11016
11017    #[cfg(windows)]
11018    #[test]
11019    fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
11020        let bogus = std::env::temp_dir().join(format!(
11021            "pinget-clean-test-bogus-{}",
11022            Utc::now().timestamp_nanos_opt().unwrap_or_default()
11023        ));
11024        assert!(!bogus.exists());
11025        clean_directory_contents(&bogus).expect("clean nonexistent dir");
11026        // Should not have created the dir.
11027        assert!(!bogus.exists());
11028    }
11029
11030    #[cfg(windows)]
11031    #[test]
11032    fn path_prefix_check_handles_mixed_separators() {
11033        // NestedInstallerFiles RelativeFilePath uses '/' in manifests, so the
11034        // path we joined can end up mixed-separator on Windows.
11035        assert!(path_starts_with_case_insensitive(
11036            Path::new(r"C:\Foo\sub/bin.exe"),
11037            Path::new(r"C:\Foo"),
11038        ));
11039    }
11040
11041    #[test]
11042    fn repository_options_capture_custom_host_settings() {
11043        let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
11044            .with_user_agent("pinget-rs-tests/1.0")
11045            .with_pre_indexed_source_auto_update_interval(None);
11046
11047        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
11048        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
11049        assert!(options.pre_indexed_source_auto_update_interval.is_none());
11050    }
11051
11052    #[test]
11053    fn dependency_install_requests_are_silent_and_not_forced() {
11054        let mut parent = InstallRequest::new(PackageQuery {
11055            source: Some("winget".to_owned()),
11056            ..PackageQuery::default()
11057        });
11058        parent.mode = InstallerMode::SilentWithProgress;
11059        parent.force = true;
11060        parent.accept_package_agreements = true;
11061        parent.ignore_security_hash = true;
11062        parent.dependency_source = Some("dependencies".to_owned());
11063
11064        let dependency =
11065            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
11066
11067        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
11068        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
11069        assert_eq!(dependency.mode, InstallerMode::Silent);
11070        assert!(!dependency.force);
11071        assert!(dependency.no_upgrade);
11072        assert!(dependency.accept_package_agreements);
11073        assert!(dependency.ignore_security_hash);
11074    }
11075
11076    #[test]
11077    fn storage_paths_use_configured_app_root() {
11078        let app_root = PathBuf::from(r"C:\temp\pinget-test");
11079        let source = SourceRecord {
11080            name: "winget/test".to_owned(),
11081            kind: SourceKind::PreIndexed,
11082            arg: "https://example.com/cache".to_owned(),
11083            identifier: "Test.Source".to_owned(),
11084            trust_level: "None".to_owned(),
11085            explicit: false,
11086            priority: 0,
11087            last_update: None,
11088            source_version: None,
11089            etag: None,
11090            last_modified: None,
11091        };
11092
11093        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
11094        assert_eq!(
11095            source_state_dir(&app_root, &source),
11096            app_root.join("sources").join("winget_test")
11097        );
11098        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
11099    }
11100
11101    #[cfg(windows)]
11102    #[test]
11103    fn packaged_layout_paths_resolve_from_packaged_app_root() {
11104        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
11105        let app_root = local_app_data
11106            .join("Packages")
11107            .join(PACKAGED_FAMILY_NAME)
11108            .join("LocalState");
11109        let source = SourceStore::default()
11110            .sources
11111            .into_iter()
11112            .find(|source| source.name == "winget")
11113            .expect("winget source");
11114        assert!(uses_packaged_layout(&app_root));
11115        assert!(
11116            user_settings_path(&app_root)
11117                .display()
11118                .to_string()
11119                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
11120        );
11121        assert!(
11122            packaged_file_cache_root(&app_root)
11123                .display()
11124                .to_string()
11125                .ends_with(&format!(
11126                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
11127                    PACKAGED_FAMILY_NAME
11128                ))
11129        );
11130        assert!(
11131            source_state_dir(&app_root, &source)
11132                .display()
11133                .to_string()
11134                .ends_with(&format!(
11135                    r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe",
11136                    PACKAGED_FAMILY_NAME
11137                ))
11138        );
11139        assert!(
11140            source_installed_db_path_with_keying(&app_root, &source, false)
11141                .display()
11142                .to_string()
11143                .ends_with(&format!(
11144                    r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db",
11145                    PACKAGED_FAMILY_NAME
11146                ))
11147        );
11148    }
11149
11150    #[cfg(windows)]
11151    #[test]
11152    fn default_app_root_outside_package_avoids_packaged_layout() {
11153        // Tests run without AppX package identity, so the default app root must not
11154        // resolve to the WinGet packaged LocalState — that location requires a
11155        // brokered/elevated writer for its secure-settings stream.
11156        let prior = std::env::var_os("PINGET_APPROOT");
11157        // SAFETY: tests in this module are not run concurrently with other env mutators.
11158        unsafe { std::env::remove_var("PINGET_APPROOT") };
11159
11160        let result = default_app_root();
11161
11162        if let Some(prior) = prior {
11163            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
11164            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
11165        }
11166
11167        let app_root = result.expect("default app root");
11168        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
11169        assert!(
11170            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
11171            "unexpected fallback app root: {app_root:?}"
11172        );
11173    }
11174
11175    #[test]
11176    fn packaged_source_stream_overlays_defaults_and_metadata() {
11177        let store = parse_packaged_source_store(
11178            Some(
11179                "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",
11180            ),
11181            Some(
11182                "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",
11183            ),
11184        )
11185        .expect("packaged store");
11186
11187        assert!(!store.sources.iter().any(|source| source.name == "winget"));
11188        let corp = store
11189            .sources
11190            .iter()
11191            .find(|source| source.name == "corp")
11192            .expect("corp source");
11193        assert_eq!(corp.kind, SourceKind::Rest);
11194        assert_eq!(corp.identifier, "Contoso.Rest");
11195        assert_eq!(corp.trust_level, "Trusted");
11196        assert!(corp.explicit);
11197        assert_eq!(corp.priority, 7);
11198        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
11199        assert_eq!(corp.etag.as_deref(), Some("etag-v1"));
11200        assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT"));
11201        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
11202    }
11203
11204    #[test]
11205    fn system_winget_export_parses_json_lines() {
11206        let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11207{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11208{"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"}
11209{"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"}
11210"#;
11211
11212        let sources = parse_system_winget_source_export(output).expect("source export");
11213
11214        assert_eq!(
11215            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
11216            vec!["winget.pro", "msstore", "winget", "winget-font"]
11217        );
11218        assert_eq!(sources[0].kind, SourceKind::Rest);
11219        assert_eq!(sources[0].arg, "https://api.winget.pro/feed");
11220        assert_eq!(sources[0].identifier, "api.winget.pro");
11221        assert_eq!(sources[0].trust_level, "Trusted");
11222        assert!(!sources[0].explicit);
11223        assert_eq!(sources[2].kind, SourceKind::PreIndexed);
11224        assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe");
11225        assert!(!sources[2].explicit);
11226    }
11227
11228    #[test]
11229    fn system_winget_export_parses_wrapped_sources() {
11230        let output = r#"{
11231  "Sources": [
11232    {
11233      "Name": "corp",
11234      "Type": "Microsoft.Rest",
11235      "Arg": "https://packages.contoso.test/api",
11236      "Identifier": "Contoso.Rest",
11237      "TrustLevel": "Trusted",
11238      "Explicit": true,
11239      "Priority": 9
11240    }
11241  ]
11242}"#;
11243
11244        let sources = parse_system_winget_source_export(output).expect("source export");
11245        let source = sources.first().expect("source");
11246        assert_eq!(sources.len(), 1);
11247        assert_eq!(source.name, "corp");
11248        assert_eq!(source.kind, SourceKind::Rest);
11249        assert_eq!(source.identifier, "Contoso.Rest");
11250        assert_eq!(source.trust_level, "Trusted");
11251        assert!(source.explicit);
11252        assert_eq!(source.priority, 9);
11253    }
11254
11255    #[test]
11256    fn system_winget_source_store_detects_secure_settings_stub() {
11257        assert!(is_secure_settings_stub(Some(
11258            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
11259        )));
11260        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
11261    }
11262
11263    #[test]
11264    fn system_winget_source_store_builds_add_arguments() {
11265        let args = build_winget_source_add_arguments(
11266            "corp",
11267            "https://packages.contoso.test/api",
11268            SourceKind::Rest,
11269            "Trusted",
11270            true,
11271        );
11272
11273        assert_eq!(
11274            args,
11275            vec![
11276                "source",
11277                "add",
11278                "--name",
11279                "corp",
11280                "--arg",
11281                "https://packages.contoso.test/api",
11282                "--type",
11283                "Microsoft.Rest",
11284                "--disable-interactivity",
11285                "--trust-level",
11286                "trusted",
11287                "--explicit",
11288            ]
11289        );
11290    }
11291
11292    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
11293        Ok(WingetSourceCommandResult {
11294            exit_code: 0,
11295            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11296"#
11297            .to_owned(),
11298            stderr: String::new(),
11299        })
11300    }
11301
11302    #[test]
11303    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
11304        let original_runner = {
11305            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11306            let original = *runner;
11307            *runner = fake_system_winget_source_export;
11308            original
11309        };
11310
11311        let result = load_packaged_store_from_streams(
11312            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
11313            None,
11314        );
11315
11316        {
11317            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11318            *runner = original_runner;
11319        }
11320
11321        let store = result.expect("packaged store").expect("packaged store");
11322        let source = store.sources.first().expect("source");
11323        assert_eq!(store.sources.len(), 1);
11324        assert_eq!(source.name, "contoso");
11325        assert_eq!(source.kind, SourceKind::Rest);
11326    }
11327
11328    #[test]
11329    fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() {
11330        let original_runner = {
11331            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11332            let original = *runner;
11333            *runner = fake_system_winget_source_export;
11334            original
11335        };
11336
11337        let app_root = temp_app_root("system-winget-mirror");
11338        let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror);
11339
11340        {
11341            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11342            *runner = original_runner;
11343        }
11344
11345        let (mode, mut store) = result.expect("mirror store");
11346        assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror);
11347        assert!(system_winget_mirror_store_path(&app_root).exists());
11348        assert!(
11349            !store_path(&app_root).exists(),
11350            "mirror mode must not write private sources.json"
11351        );
11352
11353        let source = store.sources.first_mut().expect("source");
11354        source.last_update = Some(Utc::now());
11355        source.source_version = Some("cached-contract".to_owned());
11356        source.etag = Some("\"etag\"".to_owned());
11357        source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
11358        save_system_winget_mirror_store(&app_root, &store).expect("save mirror");
11359
11360        let original_runner = {
11361            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11362            let original = *runner;
11363            *runner = fake_system_winget_source_export;
11364            original
11365        };
11366        let refreshed = refresh_system_winget_mirror_store(&app_root);
11367        {
11368            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11369            *runner = original_runner;
11370        }
11371
11372        let refreshed = refreshed.expect("refresh mirror");
11373        let source = refreshed.sources.first().expect("source");
11374        assert_eq!(source.source_version.as_deref(), Some("cached-contract"));
11375        assert_eq!(source.etag.as_deref(), Some("\"etag\""));
11376        assert_eq!(
11377            source_state_dir_with_keying(&app_root, source, true),
11378            app_root.join("sources").join("api.contoso.test")
11379        );
11380        assert_eq!(
11381            source_state_dir_with_keying(&app_root, source, false),
11382            app_root.join("sources").join("contoso")
11383        );
11384
11385        let _ = fs::remove_dir_all(&app_root);
11386    }
11387
11388    #[test]
11389    fn source_metadata_and_named_reset_round_trip() {
11390        let app_root = temp_app_root("source-reset");
11391        let result = (|| -> Result<()> {
11392            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11393            repository.add_source_with_metadata(
11394                "test",
11395                "https://example.com/test",
11396                SourceKind::Rest,
11397                Some("trusted"),
11398                true,
11399                4,
11400            )?;
11401
11402            repository.edit_source("test", Some(false), None)?;
11403            let edited = repository
11404                .list_sources()
11405                .into_iter()
11406                .find(|source| source.name == "test")
11407                .expect("source");
11408            assert_eq!(edited.trust_level, "Trusted");
11409            assert!(!edited.explicit);
11410            assert_eq!(edited.priority, 4);
11411
11412            repository.reset_source("test")?;
11413            let reset = repository
11414                .list_sources()
11415                .into_iter()
11416                .find(|source| source.name == "test")
11417                .expect("source");
11418            assert_eq!(reset.trust_level, "Trusted");
11419            assert!(!reset.explicit);
11420            assert_eq!(reset.priority, 4);
11421            assert!(reset.last_update.is_none());
11422            assert!(reset.source_version.is_none());
11423            Ok(())
11424        })();
11425
11426        let _ = fs::remove_dir_all(&app_root);
11427        result.expect("source metadata");
11428    }
11429
11430    #[test]
11431    fn explicit_sources_are_skipped_by_default_search_resolution() {
11432        let app_root = temp_app_root("source-resolution");
11433        let result = (|| -> Result<()> {
11434            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11435            repository.add_source_with_metadata(
11436                "explicit-test",
11437                "https://example.com/explicit",
11438                SourceKind::Rest,
11439                Some("trusted"),
11440                true,
11441                10,
11442            )?;
11443
11444            let indexes = repository.resolve_source_indexes(None)?;
11445            let names: Vec<_> = indexes
11446                .into_iter()
11447                .map(|index| repository.list_sources()[index].name.clone())
11448                .collect();
11449            assert!(!names.iter().any(|name| name == "explicit-test"));
11450            Ok(())
11451        })();
11452
11453        let _ = fs::remove_dir_all(&app_root);
11454        result.expect("source resolution");
11455    }
11456
11457    #[test]
11458    fn source_resolution_accepts_source_identifier() {
11459        let app_root = temp_app_root("source-identifier-resolution");
11460        let result = (|| -> Result<()> {
11461            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11462            repository.add_source_with_metadata(
11463                "winget.pro",
11464                "https://api.winget.pro/feed",
11465                SourceKind::Rest,
11466                Some("trusted"),
11467                false,
11468                0,
11469            )?;
11470            repository.store.sources[0].identifier = "api.winget.pro".to_owned();
11471
11472            let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?;
11473            assert_eq!(indexes, vec![0]);
11474            Ok(())
11475        })();
11476
11477        let _ = fs::remove_dir_all(&app_root);
11478        result.expect("source identifier resolution");
11479    }
11480
11481    #[test]
11482    fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() {
11483        let query = PackageQuery {
11484            id: Some("tessl.tessl".to_owned()),
11485            source: Some("winget".to_owned()),
11486            exact: true,
11487            ..PackageQuery::default()
11488        };
11489        let clean_miss = SearchLocatedResult {
11490            matches: Vec::new(),
11491            warnings: Vec::new(),
11492            failures: Vec::new(),
11493            truncated: false,
11494        };
11495        assert!(should_retry_show_across_system_winget_sources(
11496            true,
11497            &query,
11498            &clean_miss
11499        ));
11500
11501        let private_mode = false;
11502        assert!(!should_retry_show_across_system_winget_sources(
11503            private_mode,
11504            &query,
11505            &clean_miss
11506        ));
11507
11508        let explicit_custom_source = PackageQuery {
11509            source: Some("winget.pro".to_owned()),
11510            ..query.clone()
11511        };
11512        assert!(!should_retry_show_across_system_winget_sources(
11513            true,
11514            &explicit_custom_source,
11515            &clean_miss
11516        ));
11517
11518        let failed_source = SearchLocatedResult {
11519            failures: vec![SourceSearchFailure {
11520                source_name: "winget".to_owned(),
11521                message: "network failure".to_owned(),
11522            }],
11523            ..clean_miss
11524        };
11525        assert!(!should_retry_show_across_system_winget_sources(
11526            true,
11527            &query,
11528            &failed_source
11529        ));
11530    }
11531
11532    #[test]
11533    fn user_and_admin_settings_round_trip() {
11534        let app_root = temp_app_root("settings");
11535        let result = (|| -> Result<()> {
11536            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11537            repository.set_user_settings(
11538                &serde_json::json!({
11539                    "visual": {
11540                        "progressBar": "retro"
11541                    }
11542                }),
11543                false,
11544            )?;
11545            repository.set_user_settings(
11546                &serde_json::json!({
11547                    "experimentalFeatures": {
11548                        "directMSI": true
11549                    }
11550                }),
11551                true,
11552            )?;
11553
11554            let user_settings = repository.get_user_settings()?;
11555            assert_eq!(
11556                user_settings["visual"]["progressBar"],
11557                serde_json::Value::String("retro".to_owned())
11558            );
11559            assert_eq!(
11560                user_settings["experimentalFeatures"]["directMSI"],
11561                serde_json::Value::Bool(true)
11562            );
11563            assert!(repository.test_user_settings(
11564                &serde_json::json!({
11565                    "experimentalFeatures": {
11566                        "directMSI": true
11567                    }
11568                }),
11569                true
11570            )?);
11571
11572            repository.set_admin_setting("LocalManifestFiles", true)?;
11573            repository.set_admin_setting("InstallerHashOverride", true)?;
11574            let admin_settings = repository.get_admin_settings()?;
11575            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
11576            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
11577
11578            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
11579            let reset_one = repository.get_admin_settings()?;
11580            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
11581
11582            repository.reset_admin_setting(None, true)?;
11583            let reset_all = repository.get_admin_settings()?;
11584            for name in Repository::supported_admin_settings() {
11585                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
11586            }
11587            Ok(())
11588        })();
11589
11590        let _ = fs::remove_dir_all(&app_root);
11591        result.expect("settings");
11592    }
11593
11594    #[test]
11595    fn show_result_structured_document_is_manifest_oriented() {
11596        let result = ShowResult {
11597            package: SearchMatch {
11598                source_name: "winget".to_owned(),
11599                source_kind: SourceKind::PreIndexed,
11600                id: "Test.Package".to_owned(),
11601                name: "Test Package".to_owned(),
11602                moniker: Some("testpkg".to_owned()),
11603                version: Some("1.2.3".to_owned()),
11604                channel: Some("stable".to_owned()),
11605                match_criteria: Some("Id".to_owned()),
11606            },
11607            manifest: Manifest {
11608                id: "Test.Package".to_owned(),
11609                name: "Test Package".to_owned(),
11610                version: "1.2.3".to_owned(),
11611                channel: "stable".to_owned(),
11612                publisher: Some("Contoso".to_owned()),
11613                description: Some("Structured output".to_owned()),
11614                moniker: Some("testpkg".to_owned()),
11615                package_url: None,
11616                publisher_url: None,
11617                publisher_support_url: None,
11618                license: None,
11619                license_url: None,
11620                privacy_url: None,
11621                author: None,
11622                copyright: None,
11623                copyright_url: None,
11624                release_notes: None,
11625                release_notes_url: None,
11626                tags: vec!["utility".to_owned()],
11627                agreements: Vec::new(),
11628                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
11629                documentation: vec![Documentation {
11630                    label: Some("Docs".to_owned()),
11631                    url: "https://example.test/docs".to_owned(),
11632                }],
11633                icons: vec![PackageIcon {
11634                    icon_url: Some("https://example.test/default-256.ico".to_owned()),
11635                    icon_file_type: Some("ico".to_owned()),
11636                    icon_resolution: Some("256x256".to_owned()),
11637                    icon_theme: Some("default".to_owned()),
11638                    icon_sha256: Some("DEF456".to_owned()),
11639                }],
11640                installers: vec![Installer {
11641                    architecture: Some("x64".to_owned()),
11642                    installer_type: Some("msix".to_owned()),
11643                    url: Some("https://example.test/Test.Package.msix".to_owned()),
11644                    sha256: Some("ABC123".to_owned()),
11645                    product_code: None,
11646                    locale: Some("en-US".to_owned()),
11647                    scope: Some("machine".to_owned()),
11648                    release_date: None,
11649                    package_family_name: None,
11650                    upgrade_code: None,
11651                    platforms: Vec::new(),
11652                    minimum_os_version: None,
11653                    switches: InstallerSwitches {
11654                        silent: Some("/quiet".to_owned()),
11655                        ..InstallerSwitches::default()
11656                    },
11657                    commands: vec!["testpkg".to_owned()],
11658                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11659                    require_explicit_upgrade: false,
11660                    nested_installer_type: None,
11661                    nested_installer_files: Vec::new(),
11662                }],
11663                require_explicit_upgrade: false,
11664            },
11665            selected_installer: Some(Installer {
11666                architecture: Some("x64".to_owned()),
11667                installer_type: Some("msix".to_owned()),
11668                url: Some("https://example.test/Test.Package.msix".to_owned()),
11669                sha256: Some("ABC123".to_owned()),
11670                product_code: None,
11671                locale: Some("en-US".to_owned()),
11672                scope: Some("machine".to_owned()),
11673                release_date: None,
11674                package_family_name: None,
11675                upgrade_code: None,
11676                platforms: Vec::new(),
11677                minimum_os_version: None,
11678                switches: InstallerSwitches {
11679                    silent: Some("/quiet".to_owned()),
11680                    ..InstallerSwitches::default()
11681                },
11682                commands: vec!["testpkg".to_owned()],
11683                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11684                require_explicit_upgrade: false,
11685                nested_installer_type: None,
11686                nested_installer_files: Vec::new(),
11687            }),
11688            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
11689            warnings: vec!["cache warmed".to_owned()],
11690            manifest_documents: JsonValue::Array(vec![
11691                serde_json::json!({
11692                    "PackageIdentifier": "Test.Package",
11693                    "PackageVersion": "1.2.3",
11694                    "DefaultLocale": "en-US",
11695                    "ManifestType": "version",
11696                    "ManifestVersion": "1.10.0"
11697                }),
11698                serde_json::json!({
11699                    "PackageIdentifier": "Test.Package",
11700                    "PackageVersion": "1.2.3",
11701                    "PackageLocale": "en-US",
11702                    "PackageName": "Test Package",
11703                    "Publisher": "Example",
11704                    "License": "MIT",
11705                    "ShortDescription": "Structured output",
11706                    "Icons": [
11707                        {
11708                            "IconUrl": "https://example.test/default-256.ico",
11709                            "IconFileType": "ico",
11710                            "IconResolution": "256x256",
11711                            "IconTheme": "default",
11712                            "IconSha256": "DEF456"
11713                        }
11714                    ],
11715                    "ManifestType": "defaultLocale",
11716                    "ManifestVersion": "1.10.0"
11717                }),
11718                serde_json::json!({
11719                    "PackageIdentifier": "Test.Package",
11720                    "PackageVersion": "1.2.3",
11721                    "ManifestType": "installer",
11722                    "ManifestVersion": "1.10.0",
11723                    "Installers": [
11724                        {
11725                            "Architecture": "x64",
11726                            "InstallerType": "msix",
11727                            "InstallerUrl": "https://example.test/Test.Package.msix",
11728                            "InstallerSha256": "ABC123",
11729                            "Commands": ["testpkg"],
11730                            "InstallerSwitches": { "Silent": "/quiet" },
11731                            "Dependencies": {
11732                                "PackageDependencies": [
11733                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
11734                                ]
11735                            }
11736                        }
11737                    ]
11738                }),
11739            ]),
11740        };
11741
11742        let document = result.structured_document();
11743
11744        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
11745        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
11746        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
11747        assert_eq!(
11748            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
11749            Some("Microsoft.VCRedist.2015+.x64")
11750        );
11751        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
11752        assert_eq!(
11753            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
11754            Some("/quiet")
11755        );
11756        assert_eq!(
11757            document["IconUrl"].as_str(),
11758            Some("https://example.test/default-256.ico")
11759        );
11760        assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
11761        assert_eq!(
11762            document["Icons"][0]["IconUrl"].as_str(),
11763            Some("https://example.test/default-256.ico")
11764        );
11765    }
11766
11767    #[test]
11768    fn manifest_parses_require_explicit_upgrade_at_top_level() {
11769        // Top-level RequireExplicitUpgrade flag should propagate to
11770        // Manifest.require_explicit_upgrade. winget catalogs typically
11771        // place the flag here for browser packages and similar
11772        // self-updating apps that opt out of bulk `upgrade`.
11773        let yaml = r#"
11774PackageIdentifier: Test.Package
11775PackageVersion: 1.2.3
11776DefaultLocale: en-US
11777ManifestType: singleton
11778ManifestVersion: 1.10.0
11779PackageLocale: en-US
11780PackageName: Test Package
11781Publisher: Example
11782License: MIT
11783ShortDescription: explicit-upgrade fixture
11784RequireExplicitUpgrade: true
11785Installers:
11786  - Architecture: x64
11787    InstallerType: exe
11788    InstallerUrl: https://example.test/Test.Package.exe
11789    InstallerSha256: ABC123
11790"#;
11791        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11792        assert!(manifest.require_explicit_upgrade);
11793    }
11794
11795    #[test]
11796    fn manifest_parses_require_explicit_upgrade_on_installer() {
11797        // Per-installer flag — only one of several installers declares it,
11798        // but the Manifest aggregate must still be `true` because the user
11799        // could pick that installer when upgrading.
11800        let yaml = r#"
11801PackageIdentifier: Test.Package
11802PackageVersion: 1.2.3
11803DefaultLocale: en-US
11804ManifestType: singleton
11805ManifestVersion: 1.10.0
11806PackageLocale: en-US
11807PackageName: Test Package
11808Publisher: Example
11809License: MIT
11810ShortDescription: explicit-upgrade fixture
11811Installers:
11812  - Architecture: x64
11813    InstallerType: exe
11814    InstallerUrl: https://example.test/Test.Package.x64.exe
11815    InstallerSha256: ABC123
11816  - Architecture: arm64
11817    InstallerType: exe
11818    InstallerUrl: https://example.test/Test.Package.arm64.exe
11819    InstallerSha256: DEF456
11820    RequireExplicitUpgrade: true
11821"#;
11822        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11823        assert!(manifest.require_explicit_upgrade);
11824        assert!(!manifest.installers[0].require_explicit_upgrade);
11825        assert!(manifest.installers[1].require_explicit_upgrade);
11826    }
11827
11828    #[test]
11829    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
11830        let yaml = r#"
11831PackageIdentifier: Test.Package
11832PackageVersion: 1.2.3
11833DefaultLocale: en-US
11834ManifestType: singleton
11835ManifestVersion: 1.10.0
11836PackageLocale: en-US
11837PackageName: Test Package
11838Publisher: Example
11839License: MIT
11840ShortDescription: baseline fixture
11841Installers:
11842  - Architecture: x64
11843    InstallerType: exe
11844    InstallerUrl: https://example.test/Test.Package.exe
11845    InstallerSha256: ABC123
11846"#;
11847        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11848        assert!(!manifest.require_explicit_upgrade);
11849    }
11850
11851    #[test]
11852    fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
11853        let yaml = r#"
11854PackageIdentifier: Test.Package
11855PackageVersion: 1.2.3
11856DefaultLocale: en-US
11857ManifestType: singleton
11858ManifestVersion: 1.10.0
11859PackageLocale: en-US
11860PackageName: Test Package
11861Publisher: Example
11862License: MIT
11863ShortDescription: icon fixture
11864Icons:
11865  - IconUrl: https://example.test/dark-512.png
11866    IconFileType: png
11867    IconResolution: 512x512
11868    IconTheme: dark
11869    IconSha256: DARK
11870  - IconUrl: https://example.test/default-64.png
11871    IconFileType: png
11872    IconResolution: 64x64
11873    IconTheme: default
11874  - IconUrl: https://example.test/default-256.ico
11875    IconFileType: ico
11876    IconResolution: 256x256
11877    IconTheme: default
11878  - IconFileType: png
11879    IconResolution: 1024x1024
11880Installers:
11881  - Architecture: x64
11882    InstallerType: exe
11883    InstallerUrl: https://example.test/Test.Package.exe
11884    InstallerSha256: ABC123
11885"#;
11886        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11887
11888        assert_eq!(manifest.icons.len(), 4);
11889        assert_eq!(
11890            manifest.icons[0].icon_url.as_deref(),
11891            Some("https://example.test/dark-512.png")
11892        );
11893        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11894        let selected = manifest.icon().expect("selected icon");
11895        assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
11896        assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
11897    }
11898
11899    #[test]
11900    fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
11901        let yaml = r#"
11902PackageIdentifier: Test.Package
11903PackageVersion: 1.2.3
11904DefaultLocale: en-US
11905ManifestType: singleton
11906ManifestVersion: 1.10.0
11907PackageLocale: en-US
11908PackageName: Test Package
11909Publisher: Example
11910License: MIT
11911ShortDescription: no-icon fixture
11912Installers:
11913  - Architecture: x64
11914    InstallerType: exe
11915    InstallerUrl: https://example.test/Test.Package.exe
11916    InstallerSha256: ABC123
11917"#;
11918        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11919
11920        assert!(manifest.icons.is_empty());
11921        assert!(manifest.icon().is_none());
11922        assert!(manifest.icon_url().is_none());
11923    }
11924
11925    #[test]
11926    fn rest_manifest_parses_icons_from_default_locale() {
11927        let response = serde_json::json!({
11928            "Data": {
11929                "PackageIdentifier": "Test.Package",
11930                "Versions": [
11931                    {
11932                        "PackageVersion": "1.2.3",
11933                        "DefaultLocale": {
11934                            "PackageName": "Test Package",
11935                            "Publisher": "Example",
11936                            "ShortDescription": "REST icon fixture",
11937                            "Icons": [
11938                                {
11939                                    "IconUrl": "https://example.test/default-128.png",
11940                                    "IconFileType": "png",
11941                                    "IconResolution": "128x128",
11942                                    "IconTheme": "default"
11943                                },
11944                                {
11945                                    "IconUrl": "https://example.test/default-256.ico",
11946                                    "IconFileType": "ico",
11947                                    "IconResolution": "256x256"
11948                                }
11949                            ]
11950                        },
11951                        "Installers": [
11952                            {
11953                                "Architecture": "x64",
11954                                "InstallerType": "exe",
11955                                "InstallerUrl": "https://example.test/Test.Package.exe",
11956                                "InstallerSha256": "ABC123"
11957                            }
11958                        ],
11959                        "ManifestVersion": "1.10.0"
11960                    }
11961                ],
11962                "ManifestVersion": "1.10.0"
11963            }
11964        });
11965        let bytes = serde_json::to_vec(&response).expect("json");
11966
11967        let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
11968
11969        assert_eq!(manifest.icons.len(), 2);
11970        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11971        assert_eq!(
11972            document["Icons"][0]["IconUrl"].as_str(),
11973            Some("https://example.test/default-128.png")
11974        );
11975    }
11976
11977    #[test]
11978    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
11979        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
11980        // (Edge, Steam, Discord). pinget must do the same — the filter is
11981        // the only gate that enforces it.
11982        let mut pkg = InstalledPackage {
11983            name: "Edge".to_owned(),
11984            local_id: r"ARP\Machine\X64\Edge".to_owned(),
11985            installed_version: "100.0".to_owned(),
11986            publisher: None,
11987            scope: Some("Machine".to_owned()),
11988            installer_category: Some("exe".to_owned()),
11989            install_location: None,
11990            package_family_names: Vec::new(),
11991            product_codes: Vec::new(),
11992            upgrade_codes: Vec::new(),
11993            correlated: Some(SearchMatch {
11994                source_name: "winget".to_owned(),
11995                source_kind: SourceKind::PreIndexed,
11996                id: "Microsoft.Edge".to_owned(),
11997                name: "Microsoft Edge".to_owned(),
11998                moniker: None,
11999                version: Some("110.0".to_owned()),
12000                channel: None,
12001                match_criteria: None,
12002            }),
12003            winget_package_identifier: None,
12004            winget_source_identifier: None,
12005            installed_version_canonical: false,
12006            correlated_requires_explicit_upgrade: true,
12007            correlated_lacks_compatible_installer: false,
12008        };
12009        let bulk_query = ListQuery {
12010            upgrade_only: true,
12011            ..ListQuery::default()
12012        };
12013        assert!(
12014            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
12015            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
12016        );
12017
12018        // When the user explicitly targets it by id, winget shows it —
12019        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
12020        let filtered_query = ListQuery {
12021            upgrade_only: true,
12022            id: Some("Microsoft.Edge".to_owned()),
12023            ..ListQuery::default()
12024        };
12025        assert!(
12026            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
12027            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
12028        );
12029
12030        // Without the flag, the row appears in bulk upgrade as usual.
12031        pkg.correlated_requires_explicit_upgrade = false;
12032        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12033    }
12034
12035    #[test]
12036    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
12037        let mut pkg = InstalledPackage {
12038            name: "Foo".to_owned(),
12039            local_id: r"ARP\Machine\X64\Foo".to_owned(),
12040            installed_version: "1.0".to_owned(),
12041            publisher: None,
12042            scope: Some("Machine".to_owned()),
12043            installer_category: Some("exe".to_owned()),
12044            install_location: None,
12045            package_family_names: Vec::new(),
12046            product_codes: Vec::new(),
12047            upgrade_codes: Vec::new(),
12048            correlated: Some(SearchMatch {
12049                source_name: "winget".to_owned(),
12050                source_kind: SourceKind::PreIndexed,
12051                id: "Test.Foo".to_owned(),
12052                name: "Foo".to_owned(),
12053                moniker: None,
12054                version: Some("2.0".to_owned()),
12055                channel: None,
12056                match_criteria: None,
12057            }),
12058            winget_package_identifier: None,
12059            winget_source_identifier: None,
12060            installed_version_canonical: false,
12061            correlated_requires_explicit_upgrade: false,
12062            correlated_lacks_compatible_installer: true,
12063        };
12064        let bulk_query = ListQuery {
12065            upgrade_only: true,
12066            ..ListQuery::default()
12067        };
12068        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12069
12070        // Explicit `--id` still surfaces it.
12071        let filtered_query = ListQuery {
12072            upgrade_only: true,
12073            id: Some("Test.Foo".to_owned()),
12074            ..ListQuery::default()
12075        };
12076        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
12077
12078        // Cleared flag → visible in bulk.
12079        pkg.correlated_lacks_compatible_installer = false;
12080        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12081    }
12082
12083    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
12084        let installers = arches
12085            .iter()
12086            .map(|a| Installer {
12087                architecture: Some((*a).to_owned()),
12088                installer_type: None,
12089                url: None,
12090                sha256: None,
12091                product_code: None,
12092                locale: None,
12093                scope: None,
12094                release_date: None,
12095                package_family_name: None,
12096                upgrade_code: None,
12097                platforms: Vec::new(),
12098                minimum_os_version: None,
12099                switches: InstallerSwitches::default(),
12100                commands: Vec::new(),
12101                package_dependencies: Vec::new(),
12102                require_explicit_upgrade: false,
12103                nested_installer_type: None,
12104                nested_installer_files: Vec::new(),
12105            })
12106            .collect();
12107        Manifest {
12108            id: "Test".to_owned(),
12109            name: "Test".to_owned(),
12110            version: "1.0".to_owned(),
12111            channel: String::new(),
12112            publisher: None,
12113            description: None,
12114            moniker: None,
12115            package_url: None,
12116            publisher_url: None,
12117            publisher_support_url: None,
12118            license: None,
12119            license_url: None,
12120            privacy_url: None,
12121            author: None,
12122            copyright: None,
12123            copyright_url: None,
12124            release_notes: None,
12125            release_notes_url: None,
12126            tags: Vec::new(),
12127            agreements: Vec::new(),
12128            package_dependencies: Vec::new(),
12129            documentation: Vec::new(),
12130            icons: Vec::new(),
12131            installers,
12132            require_explicit_upgrade: false,
12133        }
12134    }
12135
12136    #[test]
12137    fn manifest_compatible_installer_neutral_always_matches() {
12138        // `neutral` is in every host's allowed list.
12139        assert!(manifest_has_compatible_installer(
12140            &synthetic_manifest_with_installer_arches(&["neutral"])
12141        ));
12142    }
12143
12144    #[test]
12145    fn manifest_compatible_installer_empty_installers_pass_through() {
12146        // Manifest with no installers (e.g. a docs-only entry): nothing to
12147        // filter against, don't hide the row.
12148        assert!(manifest_has_compatible_installer(
12149            &synthetic_manifest_with_installer_arches(&[])
12150        ));
12151    }
12152
12153    #[test]
12154    fn manifest_compatible_installer_rejects_alien_arch_only() {
12155        // `ppc` is in no host's allowed list — manifest has nothing runnable.
12156        assert!(!manifest_has_compatible_installer(
12157            &synthetic_manifest_with_installer_arches(&["ppc"])
12158        ));
12159    }
12160
12161    #[test]
12162    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
12163        // Mixed list where at least `neutral` is in every host's preference.
12164        assert!(manifest_has_compatible_installer(
12165            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
12166        ));
12167    }
12168
12169    #[test]
12170    fn lookup_unique_normalized_identity_returns_unique_match() {
12171        // Single (norm_name, norm_publisher) intersection — happy path.
12172        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12173        execute_batch_sql(
12174            &connection,
12175            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12176             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12177             INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
12178             INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
12179        )
12180        .expect("seed schema");
12181
12182        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
12183            .expect("query")
12184            .expect("unique match");
12185        assert_eq!(rowid, 100);
12186    }
12187
12188    #[test]
12189    fn lookup_installed_db_identity_match_returns_product_code_match() {
12190        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12191        execute_batch_sql(
12192            &connection,
12193            "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\
12194             CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\
12195             CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\
12196             CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\
12197             CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\
12198             INSERT INTO ids VALUES (1, 'Git.Git');\n\
12199             INSERT INTO names VALUES (1, 'Git');\n\
12200             INSERT INTO manifest VALUES (10, 1, 1);\n\
12201             INSERT INTO productcodes VALUES (20, 'git_is1');\n\
12202             INSERT INTO productcodes_map VALUES (10, 20);",
12203        )
12204        .expect("seed schema");
12205        let package = InstalledPackage {
12206            name: "Git".to_owned(),
12207            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
12208            installed_version: "2.54.0".to_owned(),
12209            publisher: None,
12210            scope: None,
12211            installer_category: None,
12212            install_location: None,
12213            package_family_names: Vec::new(),
12214            product_codes: vec!["git_is1".to_owned()],
12215            upgrade_codes: Vec::new(),
12216            correlated: None,
12217            winget_package_identifier: None,
12218            winget_source_identifier: None,
12219            installed_version_canonical: false,
12220            correlated_requires_explicit_upgrade: false,
12221            correlated_lacks_compatible_installer: false,
12222        };
12223
12224        let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package)
12225            .expect("query")
12226            .expect("product code match");
12227
12228        assert_eq!(id, "Git.Git");
12229        assert_eq!(name, "Git");
12230        assert_eq!(matched_by, "ProductCode");
12231    }
12232
12233    #[test]
12234    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
12235        // Two distinct packages share the same (norm_name, norm_publisher)
12236        // — the Git case where both `Git.Git` and a hypothetical sibling
12237        // normalize identically. winget refuses to correlate when it can't
12238        // disambiguate; pinget must do the same.
12239        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12240        execute_batch_sql(
12241            &connection,
12242            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12243             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12244             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12245             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
12246        )
12247        .expect("seed schema");
12248
12249        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12250        assert!(rowid.is_none(), "ambiguous match must not correlate");
12251    }
12252
12253    #[test]
12254    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
12255        // norm_name has multiple matches but only one shares its
12256        // norm_publisher with the installed package. Winget's intersect
12257        // logic still picks the right one — verify.
12258        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12259        execute_batch_sql(
12260            &connection,
12261            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12262             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12263             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12264             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
12265        )
12266        .expect("seed schema");
12267
12268        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12269        assert_eq!(rowid, Some(100));
12270    }
12271
12272    #[test]
12273    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
12274        // Name matches but no publisher row for that package id —
12275        // intersection is empty so we don't correlate.
12276        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12277        execute_batch_sql(
12278            &connection,
12279            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12280             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12281             INSERT INTO norm_names2 VALUES ('foo', 100);\n\
12282             INSERT INTO norm_publishers2 VALUES ('bar', 200);",
12283        )
12284        .expect("seed schema");
12285
12286        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
12287        assert!(
12288            rowid.is_none(),
12289            "name matches package 100 but publisher matches 200 — no intersection"
12290        );
12291    }
12292
12293    #[test]
12294    fn parse_yaml_manifest_bundle_returns_singleton_document() {
12295        let yaml = r#"
12296PackageIdentifier: Test.Package
12297PackageVersion: 1.2.3
12298DefaultLocale: en-US
12299ManifestType: version
12300ManifestVersion: 1.10.0
12301---
12302PackageIdentifier: Test.Package
12303PackageVersion: 1.2.3
12304PackageLocale: en-US
12305PackageName: Test Package
12306Publisher: Example
12307License: MIT
12308ShortDescription: Structured output
12309ManifestType: defaultLocale
12310ManifestVersion: 1.10.0
12311---
12312PackageIdentifier: Test.Package
12313PackageVersion: 1.2.3
12314ManifestType: installer
12315ManifestVersion: 1.10.0
12316Installers:
12317  - Architecture: x64
12318    InstallerType: exe
12319    InstallerUrl: https://example.test/Test.Package.exe
12320    InstallerSha256: ABC123
12321"#;
12322
12323        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
12324
12325        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
12326        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
12327        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
12328    }
12329
12330    #[test]
12331    fn collapse_structured_documents_returns_plural_show_documents() {
12332        let documents = collapse_structured_documents(&[
12333            JsonValue::Array(vec![
12334                serde_json::json!({
12335                    "PackageIdentifier": "Test.Package.One",
12336                    "PackageVersion": "1.0.0",
12337                    "DefaultLocale": "en-US",
12338                    "ManifestType": "version",
12339                    "ManifestVersion": "1.10.0"
12340                }),
12341                serde_json::json!({
12342                    "PackageIdentifier": "Test.Package.One",
12343                    "PackageVersion": "1.0.0",
12344                    "PackageLocale": "en-US",
12345                    "PackageName": "Test Package One",
12346                    "ManifestType": "defaultLocale",
12347                    "ManifestVersion": "1.10.0"
12348                }),
12349                serde_json::json!({
12350                    "PackageIdentifier": "Test.Package.One",
12351                    "PackageVersion": "1.0.0",
12352                    "ManifestType": "installer",
12353                    "ManifestVersion": "1.10.0",
12354                    "Installers": [
12355                        {
12356                            "Architecture": "x64",
12357                            "InstallerType": "exe",
12358                            "InstallerUrl": "https://example.test/one.exe",
12359                            "InstallerSha256": "ABC123"
12360                        }
12361                    ]
12362                }),
12363            ]),
12364            serde_json::json!({
12365                "PackageIdentifier": "Test.Package.Two",
12366                "PackageVersion": "2.0.0",
12367                "PackageLocale": "en-US",
12368                "PackageName": "Test Package Two",
12369                "ManifestType": "singleton",
12370                "ManifestVersion": "1.12.0"
12371            }),
12372        ]);
12373
12374        assert_eq!(documents.len(), 2);
12375        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
12376        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
12377        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
12378        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
12379        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
12380    }
12381
12382    #[test]
12383    fn unsupported_action_result_marks_noop_and_warning() {
12384        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
12385
12386        assert!(result.success);
12387        assert!(result.no_op);
12388        assert_eq!(result.exit_code, 0);
12389        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
12390    }
12391
12392    #[test]
12393    fn create_install_no_op_result_honors_no_upgrade() {
12394        let mut request = InstallRequest::new(PackageQuery {
12395            id: Some("Contoso.App".to_owned()),
12396            ..PackageQuery::default()
12397        });
12398        request.no_upgrade = true;
12399
12400        let manifest = Manifest {
12401            id: "Contoso.App".to_owned(),
12402            name: "Contoso App".to_owned(),
12403            version: "2.0.0".to_owned(),
12404            channel: String::new(),
12405            publisher: None,
12406            description: None,
12407            moniker: None,
12408            package_url: None,
12409            publisher_url: None,
12410            publisher_support_url: None,
12411            license: None,
12412            license_url: None,
12413            privacy_url: None,
12414            author: None,
12415            copyright: None,
12416            copyright_url: None,
12417            release_notes: None,
12418            release_notes_url: None,
12419            tags: Vec::new(),
12420            agreements: Vec::new(),
12421            package_dependencies: Vec::new(),
12422            documentation: Vec::new(),
12423            icons: Vec::new(),
12424            installers: Vec::new(),
12425            require_explicit_upgrade: false,
12426        };
12427        let existing = ListMatch {
12428            name: "Contoso App".to_owned(),
12429            id: "Contoso.App".to_owned(),
12430            local_id: "Contoso.App".to_owned(),
12431            installed_version: "1.0.0".to_owned(),
12432            available_version: Some("2.0.0".to_owned()),
12433            source_name: Some("winget".to_owned()),
12434            publisher: None,
12435            scope: None,
12436            installer_category: None,
12437            install_location: None,
12438            package_family_names: Vec::new(),
12439            product_codes: Vec::new(),
12440            upgrade_codes: Vec::new(),
12441        };
12442
12443        let result =
12444            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12445
12446        assert!(result.success);
12447        assert!(result.no_op);
12448        assert_eq!(result.version, "1.0.0");
12449        assert_eq!(
12450            result.warnings,
12451            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
12452        );
12453    }
12454
12455    #[test]
12456    fn create_install_no_op_result_skips_reinstall_when_current() {
12457        let request = InstallRequest::new(PackageQuery {
12458            id: Some("Contoso.App".to_owned()),
12459            ..PackageQuery::default()
12460        });
12461
12462        let manifest = Manifest {
12463            id: "Contoso.App".to_owned(),
12464            name: "Contoso App".to_owned(),
12465            version: "2.0.0".to_owned(),
12466            channel: String::new(),
12467            publisher: None,
12468            description: None,
12469            moniker: None,
12470            package_url: None,
12471            publisher_url: None,
12472            publisher_support_url: None,
12473            license: None,
12474            license_url: None,
12475            privacy_url: None,
12476            author: None,
12477            copyright: None,
12478            copyright_url: None,
12479            release_notes: None,
12480            release_notes_url: None,
12481            tags: Vec::new(),
12482            agreements: Vec::new(),
12483            package_dependencies: Vec::new(),
12484            documentation: Vec::new(),
12485            icons: Vec::new(),
12486            installers: Vec::new(),
12487            require_explicit_upgrade: false,
12488        };
12489        let existing = ListMatch {
12490            name: "Contoso App".to_owned(),
12491            id: "Contoso.App".to_owned(),
12492            local_id: "Contoso.App".to_owned(),
12493            installed_version: "2.0.0".to_owned(),
12494            available_version: Some("2.0.0".to_owned()),
12495            source_name: Some("winget".to_owned()),
12496            publisher: None,
12497            scope: None,
12498            installer_category: None,
12499            install_location: None,
12500            package_family_names: Vec::new(),
12501            product_codes: Vec::new(),
12502            upgrade_codes: Vec::new(),
12503        };
12504
12505        let result =
12506            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12507
12508        assert!(result.success);
12509        assert!(result.no_op);
12510        assert_eq!(result.version, "2.0.0");
12511        assert_eq!(
12512            result.warnings,
12513            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
12514        );
12515    }
12516
12517    #[test]
12518    fn collapse_structured_document_projects_merged_manifest() {
12519        let document = collapse_structured_document(&serde_json::json!({
12520            "PackageIdentifier": "Test.Package",
12521            "PackageVersion": "1.2.3",
12522            "PackageLocale": "en-US",
12523            "PackageName": "Test Package",
12524            "Publisher": "Example",
12525            "InstallerType": "exe",
12526            "Installers": [
12527                {
12528                    "Architecture": "x64",
12529                    "InstallerUrl": "https://example.test/Test.Package.exe",
12530                    "InstallerSha256": "ABC123"
12531                }
12532            ],
12533            "ManifestType": "merged",
12534            "ManifestVersion": "1.10.0"
12535        }));
12536
12537        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
12538        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
12539        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
12540        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
12541        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
12542    }
12543
12544    #[test]
12545    fn parses_fixture_manifest() {
12546        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
12547            .join("tests")
12548            .join("fixtures")
12549            .join("ManifestV1_28-Singleton.yaml");
12550        let bytes = fs::read(root).expect("fixture bytes");
12551        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
12552        assert_eq!(manifest.id, "microsoft.msixsdk");
12553        assert_eq!(manifest.version, "1.7.32");
12554        assert_eq!(manifest.name, "MSIX SDK");
12555        assert!(!manifest.installers.is_empty());
12556    }
12557
12558    #[test]
12559    fn parses_installer_switches_from_yaml_manifest() {
12560        let yaml = r#"
12561PackageIdentifier: Test.Package
12562PackageVersion: 1.2.3
12563PackageName: Test Package
12564InstallerSwitches:
12565  SilentWithProgress: /SILENT
12566Installers:
12567  - Architecture: x64
12568    InstallerType: inno
12569    InstallerUrl: https://example.test/Test.Package.exe
12570    InstallerSha256: ABC123
12571    InstallerSwitches:
12572      Silent: /VERYSILENT
12573      Interactive: /HELP
12574"#;
12575
12576        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12577        let installer = &manifest.installers[0];
12578
12579        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
12580        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
12581        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
12582    }
12583
12584    #[test]
12585    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
12586        let yaml = r#"
12587PackageIdentifier: Test.Package
12588PackageVersion: 1.2.3
12589PackageName: Test Package
12590Platform:
12591  - Windows.Desktop
12592MinimumOSVersion: 10.0.19041.0
12593Installers:
12594  - Architecture: x64
12595    InstallerType: exe
12596    InstallerUrl: https://example.test/Test.Package.exe
12597    InstallerSha256: ABC123
12598  - Architecture: x64
12599    InstallerType: msix
12600    Platform:
12601      - Windows.Universal
12602    MinimumOSVersion: 10.0.22621.0
12603    InstallerUrl: https://example.test/Test.Package.msix
12604    InstallerSha256: DEF456
12605"#;
12606
12607        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12608
12609        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
12610        assert_eq!(
12611            manifest.installers[0].minimum_os_version.as_deref(),
12612            Some("10.0.19041.0")
12613        );
12614        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
12615        assert_eq!(
12616            manifest.installers[1].minimum_os_version.as_deref(),
12617            Some("10.0.22621.0")
12618        );
12619    }
12620
12621    #[test]
12622    fn installer_switch_arguments_prefer_manifest_switches() {
12623        let installer = Installer {
12624            architecture: None,
12625            installer_type: Some("inno".to_owned()),
12626            url: None,
12627            sha256: None,
12628            product_code: None,
12629            locale: None,
12630            scope: None,
12631            release_date: None,
12632            package_family_name: None,
12633            upgrade_code: None,
12634            platforms: Vec::new(),
12635            minimum_os_version: None,
12636            switches: InstallerSwitches {
12637                silent: Some("/mysilent".to_owned()),
12638                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
12639                interactive: Some("/myinteractive".to_owned()),
12640                ..InstallerSwitches::default()
12641            },
12642            commands: Vec::new(),
12643            package_dependencies: Vec::new(),
12644            require_explicit_upgrade: false,
12645            nested_installer_type: None,
12646            nested_installer_files: Vec::new(),
12647        };
12648        let manifest = Manifest {
12649            id: "Test.Package".to_owned(),
12650            name: "Test Package".to_owned(),
12651            version: "1.0.0".to_owned(),
12652            channel: String::new(),
12653            publisher: None,
12654            description: None,
12655            moniker: None,
12656            package_url: None,
12657            publisher_url: None,
12658            publisher_support_url: None,
12659            license: None,
12660            license_url: None,
12661            privacy_url: None,
12662            author: None,
12663            copyright: None,
12664            copyright_url: None,
12665            release_notes: None,
12666            release_notes_url: None,
12667            tags: Vec::new(),
12668            agreements: Vec::new(),
12669            package_dependencies: Vec::new(),
12670            documentation: Vec::new(),
12671            icons: Vec::new(),
12672            installers: Vec::new(),
12673            require_explicit_upgrade: false,
12674        };
12675
12676        let mut silent_request = InstallRequest::new(PackageQuery::default());
12677        silent_request.mode = InstallerMode::Silent;
12678        let mut progress_request = InstallRequest::new(PackageQuery::default());
12679        progress_request.mode = InstallerMode::SilentWithProgress;
12680        let mut interactive_request = InstallRequest::new(PackageQuery::default());
12681        interactive_request.mode = InstallerMode::Interactive;
12682        assert_eq!(
12683            installer_command_arguments(
12684                "inno",
12685                &silent_request,
12686                &manifest,
12687                Path::new("installer.exe"),
12688                &installer
12689            ),
12690            vec!["/mysilent".to_owned()]
12691        );
12692        assert_eq!(
12693            installer_command_arguments(
12694                "inno",
12695                &progress_request,
12696                &manifest,
12697                Path::new("installer.exe"),
12698                &installer
12699            ),
12700            vec!["/mysilentwithprogress".to_owned()]
12701        );
12702        assert_eq!(
12703            installer_command_arguments(
12704                "inno",
12705                &interactive_request,
12706                &manifest,
12707                Path::new("installer.exe"),
12708                &installer
12709            ),
12710            vec!["/myinteractive".to_owned()]
12711        );
12712    }
12713
12714    #[test]
12715    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
12716        let installer = Installer {
12717            architecture: None,
12718            installer_type: Some("inno".to_owned()),
12719            url: None,
12720            sha256: None,
12721            product_code: None,
12722            locale: None,
12723            scope: None,
12724            release_date: None,
12725            package_family_name: None,
12726            upgrade_code: None,
12727            platforms: Vec::new(),
12728            minimum_os_version: None,
12729            switches: InstallerSwitches::default(),
12730            commands: Vec::new(),
12731            package_dependencies: Vec::new(),
12732            require_explicit_upgrade: false,
12733            nested_installer_type: None,
12734            nested_installer_files: Vec::new(),
12735        };
12736        let manifest = Manifest {
12737            id: "Test.Package".to_owned(),
12738            name: "Test Package".to_owned(),
12739            version: "1.0.0".to_owned(),
12740            channel: String::new(),
12741            publisher: None,
12742            description: None,
12743            moniker: None,
12744            package_url: None,
12745            publisher_url: None,
12746            publisher_support_url: None,
12747            license: None,
12748            license_url: None,
12749            privacy_url: None,
12750            author: None,
12751            copyright: None,
12752            copyright_url: None,
12753            release_notes: None,
12754            release_notes_url: None,
12755            tags: Vec::new(),
12756            agreements: Vec::new(),
12757            package_dependencies: Vec::new(),
12758            documentation: Vec::new(),
12759            icons: Vec::new(),
12760            installers: Vec::new(),
12761            require_explicit_upgrade: false,
12762        };
12763        let mut progress_request = InstallRequest::new(PackageQuery::default());
12764        progress_request.mode = InstallerMode::SilentWithProgress;
12765        let mut silent_request = InstallRequest::new(PackageQuery::default());
12766        silent_request.mode = InstallerMode::Silent;
12767
12768        assert_eq!(
12769            installer_command_arguments(
12770                "inno",
12771                &progress_request,
12772                &manifest,
12773                Path::new("installer.exe"),
12774                &installer
12775            ),
12776            vec![
12777                "/SP-".to_owned(),
12778                "/SILENT".to_owned(),
12779                "/SUPPRESSMSGBOXES".to_owned(),
12780                "/NORESTART".to_owned()
12781            ]
12782        );
12783        assert_eq!(
12784            installer_command_arguments(
12785                "inno",
12786                &silent_request,
12787                &manifest,
12788                Path::new("installer.exe"),
12789                &installer
12790            ),
12791            vec![
12792                "/SP-".to_owned(),
12793                "/VERYSILENT".to_owned(),
12794                "/SUPPRESSMSGBOXES".to_owned(),
12795                "/NORESTART".to_owned()
12796            ]
12797        );
12798    }
12799
12800    #[test]
12801    fn installer_command_arguments_append_manifest_and_cli_switches() {
12802        let installer = Installer {
12803            architecture: None,
12804            installer_type: Some("msi".to_owned()),
12805            url: None,
12806            sha256: None,
12807            product_code: None,
12808            locale: None,
12809            scope: None,
12810            release_date: None,
12811            package_family_name: None,
12812            upgrade_code: None,
12813            platforms: Vec::new(),
12814            minimum_os_version: None,
12815            switches: InstallerSwitches {
12816                custom: Some("ADDLOCAL=Core".to_owned()),
12817                log: Some("/log \"<LOGPATH>\"".to_owned()),
12818                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
12819                ..InstallerSwitches::default()
12820            },
12821            commands: Vec::new(),
12822            package_dependencies: Vec::new(),
12823            require_explicit_upgrade: false,
12824            nested_installer_type: None,
12825            nested_installer_files: Vec::new(),
12826        };
12827        let manifest = Manifest {
12828            id: "ShareX.ShareX".to_owned(),
12829            name: "ShareX".to_owned(),
12830            version: "19.0.2".to_owned(),
12831            channel: String::new(),
12832            publisher: None,
12833            description: None,
12834            moniker: None,
12835            package_url: None,
12836            publisher_url: None,
12837            publisher_support_url: None,
12838            license: None,
12839            license_url: None,
12840            privacy_url: None,
12841            author: None,
12842            copyright: None,
12843            copyright_url: None,
12844            release_notes: None,
12845            release_notes_url: None,
12846            tags: Vec::new(),
12847            agreements: Vec::new(),
12848            package_dependencies: Vec::new(),
12849            documentation: Vec::new(),
12850            icons: Vec::new(),
12851            installers: Vec::new(),
12852            require_explicit_upgrade: false,
12853        };
12854        let mut request = InstallRequest::new(PackageQuery::default());
12855        request.mode = InstallerMode::Silent;
12856        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
12857        request.custom = Some("REBOOT=ReallySuppress".to_owned());
12858        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
12859
12860        assert_eq!(
12861            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
12862            vec![
12863                "/i".to_owned(),
12864                r"C:\temp\ShareX.msi".to_owned(),
12865                "/quiet".to_owned(),
12866                "/norestart".to_owned(),
12867                "/log".to_owned(),
12868                r"C:\temp\winget.log".to_owned(),
12869                "ADDLOCAL=Core".to_owned(),
12870                "REBOOT=ReallySuppress".to_owned(),
12871                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
12872            ]
12873        );
12874    }
12875
12876    #[test]
12877    fn msi_shell_execute_arguments_include_msiexec_target() {
12878        let installer = Installer {
12879            architecture: None,
12880            installer_type: Some("wix".to_owned()),
12881            url: None,
12882            sha256: None,
12883            product_code: None,
12884            locale: None,
12885            scope: None,
12886            release_date: None,
12887            package_family_name: None,
12888            upgrade_code: None,
12889            platforms: Vec::new(),
12890            minimum_os_version: None,
12891            switches: InstallerSwitches {
12892                log: Some("/log \"<LOGPATH>\"".to_owned()),
12893                install_location: Some("INSTALLDIR=\"<INSTALLPATH>\"".to_owned()),
12894                ..InstallerSwitches::default()
12895            },
12896            commands: Vec::new(),
12897            package_dependencies: Vec::new(),
12898            require_explicit_upgrade: false,
12899            nested_installer_type: None,
12900            nested_installer_files: Vec::new(),
12901        };
12902        let mut request = InstallRequest::new(PackageQuery::default());
12903        request.mode = InstallerMode::Silent;
12904        request.log_path = Some(PathBuf::from(r"C:\temp\node.log"));
12905        request.install_location = Some(r"C:\Program Files\nodejs".to_owned());
12906
12907        assert_eq!(
12908            installer_shell_execute_arguments(
12909                "wix",
12910                &request,
12911                &Manifest {
12912                    id: "OpenJS.NodeJS.22".to_owned(),
12913                    name: "Node.js".to_owned(),
12914                    version: "22.22.3".to_owned(),
12915                    channel: String::new(),
12916                    publisher: None,
12917                    description: None,
12918                    moniker: None,
12919                    package_url: None,
12920                    publisher_url: None,
12921                    publisher_support_url: None,
12922                    license: None,
12923                    license_url: None,
12924                    privacy_url: None,
12925                    author: None,
12926                    copyright: None,
12927                    copyright_url: None,
12928                    release_notes: None,
12929                    release_notes_url: None,
12930                    tags: Vec::new(),
12931                    agreements: Vec::new(),
12932                    package_dependencies: Vec::new(),
12933                    documentation: Vec::new(),
12934                    icons: Vec::new(),
12935                    installers: Vec::new(),
12936                    require_explicit_upgrade: false,
12937                },
12938                Path::new(r"C:\temp\node.msi"),
12939                &installer
12940            ),
12941            vec![
12942                "/i".to_owned(),
12943                r"C:\temp\node.msi".to_owned(),
12944                "/quiet".to_owned(),
12945                "/norestart".to_owned(),
12946                "/log".to_owned(),
12947                r"C:\temp\node.log".to_owned(),
12948                r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
12949            ]
12950        );
12951    }
12952
12953    #[test]
12954    fn msi_direct_arguments_omit_msiexec_target() {
12955        let installer = Installer::default();
12956        let mut request = InstallRequest::new(PackageQuery::default());
12957        request.mode = InstallerMode::Silent;
12958
12959        assert_eq!(
12960            installer_direct_msi_arguments(
12961                "wix",
12962                &request,
12963                &Manifest {
12964                    id: "Test.Package".to_owned(),
12965                    name: "Test".to_owned(),
12966                    version: "1.0.0".to_owned(),
12967                    channel: String::new(),
12968                    publisher: None,
12969                    description: None,
12970                    moniker: None,
12971                    package_url: None,
12972                    publisher_url: None,
12973                    publisher_support_url: None,
12974                    license: None,
12975                    license_url: None,
12976                    privacy_url: None,
12977                    author: None,
12978                    copyright: None,
12979                    copyright_url: None,
12980                    release_notes: None,
12981                    release_notes_url: None,
12982                    tags: Vec::new(),
12983                    agreements: Vec::new(),
12984                    package_dependencies: Vec::new(),
12985                    documentation: Vec::new(),
12986                    icons: Vec::new(),
12987                    installers: Vec::new(),
12988                    require_explicit_upgrade: false,
12989                },
12990                Path::new(r"C:\temp\test.msi"),
12991                &installer
12992            ),
12993            vec!["/quiet".to_owned(), "/norestart".to_owned()]
12994        );
12995    }
12996
12997    #[test]
12998    fn msi_direct_arguments_enable_uac_only_for_quiet_ui() {
12999        let args = vec![
13000            "/quiet".to_owned(),
13001            "/norestart".to_owned(),
13002            "/log".to_owned(),
13003            r"C:\temp\node.log".to_owned(),
13004            r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13005        ];
13006
13007        let parsed = parse_msi_direct_arguments(&args).expect("parsed");
13008
13009        assert_eq!(parsed.ui_level, 0x102);
13010        assert_eq!(
13011            parsed.properties,
13012            r#"REBOOT=ReallySuppress INSTALLDIR="C:\Program Files\nodejs""#
13013        );
13014        assert_eq!(parsed.log_path.as_deref(), Some(r"C:\temp\node.log"));
13015    }
13016
13017    #[cfg(windows)]
13018    #[test]
13019    fn machine_scope_silent_msi_uses_shell_execute_instead_of_direct_msi() {
13020        let mut request = InstallRequest::new(PackageQuery::default());
13021        request.mode = InstallerMode::Silent;
13022
13023        let installer = Installer {
13024            installer_type: Some("wix".to_owned()),
13025            scope: Some("machine".to_owned()),
13026            ..Installer::default()
13027        };
13028
13029        assert!(!should_run_msi_direct(&request, &installer));
13030        assert!(should_elevate_msi_shell_execute(&installer));
13031    }
13032
13033    #[test]
13034    fn installer_download_if_range_prefers_strong_etag_and_rejects_weak_etag() {
13035        let strong = InstallerDownloadMetadata {
13036            url: "https://example.test/app.exe".to_owned(),
13037            etag: Some(r#""abc""#.to_owned()),
13038            last_modified: Some("Wed, 03 Jun 2026 21:00:00 GMT".to_owned()),
13039            total_length: Some(100),
13040            offset: 50,
13041        };
13042        let weak = InstallerDownloadMetadata {
13043            etag: Some(r#"W/"abc""#.to_owned()),
13044            ..strong.clone()
13045        };
13046
13047        assert_eq!(installer_download_if_range(&strong).as_deref(), Some(r#""abc""#));
13048        assert_eq!(
13049            installer_download_if_range(&weak).as_deref(),
13050            Some("Wed, 03 Jun 2026 21:00:00 GMT")
13051        );
13052    }
13053
13054    #[test]
13055    fn parse_content_range_validates_resume_start_and_total() {
13056        let value = HeaderValue::from_static("bytes 50-99/100");
13057        let parsed = parse_content_range(Some(&value), 50).expect("content range");
13058
13059        assert_eq!(parsed.total_length, Some(100));
13060        assert!(parse_content_range(Some(&value), 49).is_err());
13061    }
13062
13063    #[test]
13064    fn installer_download_sidecar_paths_append_suffixes() {
13065        let path = Path::new(r"C:\temp\installer.msi");
13066
13067        assert_eq!(sidecar_path(path, "part"), PathBuf::from(r"C:\temp\installer.msi.part"));
13068        assert_eq!(
13069            sidecar_path(path, "part.json"),
13070            PathBuf::from(r"C:\temp\installer.msi.part.json")
13071        );
13072    }
13073
13074    #[test]
13075    fn shell_execute_argument_joining_quotes_spaces() {
13076        assert_eq!(
13077            join_windows_arguments(&[
13078                "/quiet".to_owned(),
13079                r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13080                r#"VALUE=has "quotes""#.to_owned(),
13081            ]),
13082            r#"/quiet "INSTALLDIR=C:\Program Files\nodejs" "VALUE=has \"quotes\"""#
13083        );
13084    }
13085
13086    #[test]
13087    fn decompresses_mszyml_payload() {
13088        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
13089        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13090        encoder.write_all(payload.as_bytes()).expect("write payload");
13091        let compressed = encoder.finish().expect("finish payload");
13092        let mut mszip = b"CK".to_vec();
13093        mszip.extend_from_slice(&compressed);
13094
13095        let decompressed = decompress_mszyml(&mszip).expect("decompress");
13096        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13097        assert_eq!(parsed.versions[0].version, "1.2.3");
13098    }
13099
13100    #[test]
13101    fn decompresses_mszyml_payload_with_prefix() {
13102        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
13103        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13104        encoder.write_all(payload.as_bytes()).expect("write payload");
13105        let compressed = encoder.finish().expect("finish payload");
13106        let mut prefixed = vec![0u8; 28];
13107        prefixed.extend_from_slice(b"CK");
13108        prefixed.extend_from_slice(&compressed);
13109
13110        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
13111        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13112        assert_eq!(parsed.versions[0].version, "1.2.3");
13113    }
13114
13115    #[test]
13116    fn compares_versions_naturally() {
13117        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
13118        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
13119        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
13120        assert_eq!(compare_version("25.4.1", "< 25.4.1"), Ordering::Greater);
13121        assert_eq!(compare_version("< 25.4.1", "25.4.1"), Ordering::Less);
13122    }
13123
13124    #[test]
13125    fn installer_hash_verification_fails_on_mismatch_without_override() {
13126        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
13127        assert!(result.is_err());
13128        assert!(
13129            result
13130                .expect_err("hash mismatch")
13131                .to_string()
13132                .contains("Installer hash mismatch")
13133        );
13134    }
13135
13136    #[test]
13137    fn installer_hash_verification_allows_mismatch_with_override() {
13138        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
13139        verify_installer_hash(None, b"pinget", false).expect("missing hash");
13140    }
13141
13142    #[test]
13143    fn search_query_many_includes_tag_and_command_conditions() {
13144        let query = PackageQuery {
13145            query: Some("terminal".to_owned()),
13146            ..PackageQuery::default()
13147        };
13148
13149        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13150
13151        assert!(where_clause.contains("tags2"));
13152        assert!(where_clause.contains("commands2"));
13153        assert_eq!(params.len(), 5);
13154    }
13155
13156    #[test]
13157    fn tag_and_command_conditions_are_non_correlated() {
13158        // The tag/command map lookups must be emitted as non-correlated
13159        // `rowid IN (SELECT ...)` subqueries. A correlated `EXISTS (... WHERE
13160        // map.owner = packages.rowid ...)` re-scans the un-indexed map tables
13161        // per package row, which is quadratic (~19s on the real winget index
13162        // under SQLite, and an effective hang under Turso). Guard against a
13163        // regression back to the correlated form.
13164        let query = PackageQuery {
13165            query: Some("terminal".to_owned()),
13166            ..PackageQuery::default()
13167        };
13168
13169        let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13170
13171        assert!(where_clause.contains("packages.rowid IN (SELECT"));
13172        assert!(!where_clause.contains("EXISTS"));
13173        assert!(!where_clause.contains("= packages.rowid"));
13174    }
13175
13176    #[test]
13177    fn show_query_single_omits_tag_and_command_conditions() {
13178        let query = PackageQuery {
13179            query: Some("terminal".to_owned()),
13180            ..PackageQuery::default()
13181        };
13182
13183        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
13184
13185        assert!(!where_clause.contains("tags2"));
13186        assert!(!where_clause.contains("commands2"));
13187        assert_eq!(params.len(), 3);
13188    }
13189
13190    #[test]
13191    fn explicit_tag_filter_uses_exact_match() {
13192        let query = PackageQuery {
13193            tag: Some("terminal".to_owned()),
13194            ..PackageQuery::default()
13195        };
13196
13197        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13198
13199        assert!(where_clause.contains("tags2"));
13200        assert_eq!(params, vec!["terminal".to_owned()]);
13201    }
13202
13203    #[test]
13204    fn rest_tag_filter_uses_exact_match() {
13205        let query = PackageQuery {
13206            tag: Some("terminal".to_owned()),
13207            ..PackageQuery::default()
13208        };
13209        let info = RestInformation {
13210            required_package_match_fields: Vec::new(),
13211            unsupported_package_match_fields: Vec::new(),
13212            required_query_parameters: Vec::new(),
13213            ..RestInformation::default()
13214        };
13215
13216        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
13217        let filters = body["Filters"].as_array().expect("filters");
13218
13219        assert_eq!(filters.len(), 1);
13220        assert_eq!(filters[0]["PackageMatchField"], "Tag");
13221        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
13222        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
13223    }
13224
13225    #[test]
13226    fn selects_installer_using_requested_filters() {
13227        let installers = vec![
13228            Installer {
13229                architecture: Some("x86".to_owned()),
13230                installer_type: Some("zip".to_owned()),
13231                url: None,
13232                sha256: None,
13233                product_code: None,
13234                locale: Some("en-US".to_owned()),
13235                scope: Some("user".to_owned()),
13236                release_date: None,
13237                package_family_name: None,
13238                upgrade_code: None,
13239                platforms: Vec::new(),
13240                minimum_os_version: None,
13241                switches: InstallerSwitches::default(),
13242                commands: Vec::new(),
13243                package_dependencies: Vec::new(),
13244                require_explicit_upgrade: false,
13245                nested_installer_type: None,
13246                nested_installer_files: Vec::new(),
13247            },
13248            Installer {
13249                architecture: Some("x64".to_owned()),
13250                installer_type: Some("msix".to_owned()),
13251                url: None,
13252                sha256: None,
13253                product_code: None,
13254                locale: Some("en-US".to_owned()),
13255                scope: Some("user".to_owned()),
13256                release_date: None,
13257                package_family_name: None,
13258                upgrade_code: None,
13259                platforms: Vec::new(),
13260                minimum_os_version: None,
13261                switches: InstallerSwitches::default(),
13262                commands: vec!["demo".to_owned()],
13263                package_dependencies: Vec::new(),
13264                require_explicit_upgrade: false,
13265                nested_installer_type: None,
13266                nested_installer_files: Vec::new(),
13267            },
13268        ];
13269        let query = PackageQuery {
13270            installer_type: Some("msix".to_owned()),
13271            installer_architecture: Some("x64".to_owned()),
13272            install_scope: Some("user".to_owned()),
13273            locale: Some("en-US".to_owned()),
13274            ..PackageQuery::default()
13275        };
13276
13277        let selected = select_installer(&installers, &query).expect("selected installer");
13278        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
13279        assert_eq!(selected.architecture.as_deref(), Some("x64"));
13280    }
13281
13282    #[test]
13283    fn selects_first_installer_when_rank_ties() {
13284        let installers = vec![
13285            Installer {
13286                architecture: Some("x64".to_owned()),
13287                installer_type: Some("exe".to_owned()),
13288                url: None,
13289                sha256: None,
13290                product_code: None,
13291                locale: Some("en-US".to_owned()),
13292                scope: Some("machine".to_owned()),
13293                release_date: None,
13294                package_family_name: None,
13295                upgrade_code: None,
13296                platforms: Vec::new(),
13297                minimum_os_version: None,
13298                switches: InstallerSwitches::default(),
13299                commands: Vec::new(),
13300                package_dependencies: Vec::new(),
13301                require_explicit_upgrade: false,
13302                nested_installer_type: None,
13303                nested_installer_files: Vec::new(),
13304            },
13305            Installer {
13306                architecture: Some("x64".to_owned()),
13307                installer_type: Some("exe".to_owned()),
13308                url: None,
13309                sha256: None,
13310                product_code: None,
13311                locale: Some("en-US".to_owned()),
13312                scope: Some("user".to_owned()),
13313                release_date: None,
13314                package_family_name: None,
13315                upgrade_code: None,
13316                platforms: Vec::new(),
13317                minimum_os_version: None,
13318                switches: InstallerSwitches::default(),
13319                commands: Vec::new(),
13320                package_dependencies: Vec::new(),
13321                require_explicit_upgrade: false,
13322                nested_installer_type: None,
13323                nested_installer_files: Vec::new(),
13324            },
13325        ];
13326
13327        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
13328        assert_eq!(selected.scope.as_deref(), Some("machine"));
13329    }
13330
13331    #[test]
13332    fn selects_installer_using_requested_platform() {
13333        let installers = vec![
13334            Installer {
13335                architecture: Some("x64".to_owned()),
13336                installer_type: Some("exe".to_owned()),
13337                url: None,
13338                sha256: None,
13339                product_code: None,
13340                locale: Some("en-US".to_owned()),
13341                scope: Some("user".to_owned()),
13342                release_date: None,
13343                package_family_name: None,
13344                upgrade_code: None,
13345                platforms: vec!["Windows.Universal".to_owned()],
13346                minimum_os_version: None,
13347                switches: InstallerSwitches::default(),
13348                commands: Vec::new(),
13349                package_dependencies: Vec::new(),
13350                require_explicit_upgrade: false,
13351                nested_installer_type: None,
13352                nested_installer_files: Vec::new(),
13353            },
13354            Installer {
13355                architecture: Some("x64".to_owned()),
13356                installer_type: Some("exe".to_owned()),
13357                url: None,
13358                sha256: None,
13359                product_code: None,
13360                locale: Some("en-US".to_owned()),
13361                scope: Some("user".to_owned()),
13362                release_date: None,
13363                package_family_name: None,
13364                upgrade_code: None,
13365                platforms: vec!["Windows.Desktop".to_owned()],
13366                minimum_os_version: None,
13367                switches: InstallerSwitches::default(),
13368                commands: Vec::new(),
13369                package_dependencies: Vec::new(),
13370                require_explicit_upgrade: false,
13371                nested_installer_type: None,
13372                nested_installer_files: Vec::new(),
13373            },
13374        ];
13375        let query = PackageQuery {
13376            installer_type: Some("exe".to_owned()),
13377            platform: Some("Windows.Desktop".to_owned()),
13378            ..PackageQuery::default()
13379        };
13380
13381        let selected = select_installer(&installers, &query).expect("selected installer");
13382        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
13383    }
13384
13385    #[test]
13386    fn selects_installer_using_requested_os_version() {
13387        let installers = vec![
13388            Installer {
13389                architecture: Some("x64".to_owned()),
13390                installer_type: Some("exe".to_owned()),
13391                url: None,
13392                sha256: None,
13393                product_code: None,
13394                locale: Some("en-US".to_owned()),
13395                scope: Some("user".to_owned()),
13396                release_date: None,
13397                package_family_name: None,
13398                upgrade_code: None,
13399                platforms: Vec::new(),
13400                minimum_os_version: Some("10.0.22621.0".to_owned()),
13401                switches: InstallerSwitches::default(),
13402                commands: Vec::new(),
13403                package_dependencies: Vec::new(),
13404                require_explicit_upgrade: false,
13405                nested_installer_type: None,
13406                nested_installer_files: Vec::new(),
13407            },
13408            Installer {
13409                architecture: Some("x64".to_owned()),
13410                installer_type: Some("exe".to_owned()),
13411                url: None,
13412                sha256: None,
13413                product_code: None,
13414                locale: Some("en-US".to_owned()),
13415                scope: Some("user".to_owned()),
13416                release_date: None,
13417                package_family_name: None,
13418                upgrade_code: None,
13419                platforms: Vec::new(),
13420                minimum_os_version: Some("10.0.19041.0".to_owned()),
13421                switches: InstallerSwitches::default(),
13422                commands: Vec::new(),
13423                package_dependencies: Vec::new(),
13424                require_explicit_upgrade: false,
13425                nested_installer_type: None,
13426                nested_installer_files: Vec::new(),
13427            },
13428        ];
13429        let query = PackageQuery {
13430            installer_type: Some("exe".to_owned()),
13431            os_version: Some("10.0.19045.0".to_owned()),
13432            ..PackageQuery::default()
13433        };
13434
13435        let selected = select_installer(&installers, &query).expect("selected installer");
13436        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
13437    }
13438
13439    #[test]
13440    fn derives_correlation_name_candidates() {
13441        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
13442        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
13443        assert!(candidates.contains(&"PowerToys".to_owned()));
13444    }
13445
13446    #[test]
13447    fn correlates_installed_package_to_available_match() {
13448        let installed = InstalledPackage {
13449            name: "PowerToys (Preview) x64".to_owned(),
13450            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13451            installed_version: "0.98.1".to_owned(),
13452            publisher: None,
13453            scope: Some("Machine".to_owned()),
13454            installer_category: Some("exe".to_owned()),
13455            install_location: None,
13456            package_family_names: Vec::new(),
13457            product_codes: Vec::new(),
13458            upgrade_codes: Vec::new(),
13459            correlated: None,
13460            winget_package_identifier: None,
13461            winget_source_identifier: None,
13462            installed_version_canonical: false,
13463            correlated_requires_explicit_upgrade: false,
13464            correlated_lacks_compatible_installer: false,
13465        };
13466        let candidates = vec![SearchMatch {
13467            source_name: "winget".to_owned(),
13468            source_kind: SourceKind::PreIndexed,
13469            id: "Microsoft.PowerToys".to_owned(),
13470            name: "PowerToys".to_owned(),
13471            moniker: None,
13472            version: Some("0.98.1".to_owned()),
13473            channel: None,
13474            match_criteria: None,
13475        }];
13476
13477        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13478        assert_eq!(correlated.id, "Microsoft.PowerToys");
13479    }
13480
13481    #[test]
13482    fn list_query_uses_available_lookup_for_tag_filters() {
13483        let query = ListQuery {
13484            tag: Some("terminal".to_owned()),
13485            ..ListQuery::default()
13486        };
13487
13488        assert!(list_query_needs_available_lookup(&query));
13489    }
13490
13491    #[test]
13492    fn list_tag_filter_requires_correlated_match() {
13493        let package = InstalledPackage {
13494            name: "PowerToys (Preview) x64".to_owned(),
13495            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13496            installed_version: "0.98.1".to_owned(),
13497            publisher: None,
13498            scope: Some("Machine".to_owned()),
13499            installer_category: Some("exe".to_owned()),
13500            install_location: None,
13501            package_family_names: Vec::new(),
13502            product_codes: Vec::new(),
13503            upgrade_codes: Vec::new(),
13504            correlated: None,
13505            winget_package_identifier: None,
13506            winget_source_identifier: None,
13507            installed_version_canonical: false,
13508            correlated_requires_explicit_upgrade: false,
13509            correlated_lacks_compatible_installer: false,
13510        };
13511        let query = ListQuery {
13512            tag: Some("powertoys".to_owned()),
13513            ..ListQuery::default()
13514        };
13515
13516        assert!(!list_package_matches(&package, &query));
13517    }
13518
13519    #[test]
13520    fn list_lookup_ignores_display_count() {
13521        let query = ListQuery {
13522            query: Some("git".to_owned()),
13523            count: Some(5),
13524            ..ListQuery::default()
13525        };
13526
13527        let package_query = package_query_from_list_query(&query);
13528        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
13529    }
13530
13531    #[test]
13532    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
13533        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
13534        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
13535        // whichever candidate happened to come last in iteration order. The installed
13536        // display name's prefix decides the correct one.
13537        let installed = InstalledPackage {
13538            name: "Notepad++ (ARM 64-bit)".to_owned(),
13539            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
13540            installed_version: "8.8.9".to_owned(),
13541            publisher: None,
13542            scope: Some("Machine".to_owned()),
13543            installer_category: Some("exe".to_owned()),
13544            install_location: None,
13545            package_family_names: Vec::new(),
13546            product_codes: Vec::new(),
13547            upgrade_codes: Vec::new(),
13548            correlated: None,
13549            winget_package_identifier: None,
13550            winget_source_identifier: None,
13551            installed_version_canonical: false,
13552            correlated_requires_explicit_upgrade: false,
13553            correlated_lacks_compatible_installer: false,
13554        };
13555        let candidates = vec![
13556            SearchMatch {
13557                source_name: "winget".to_owned(),
13558                source_kind: SourceKind::PreIndexed,
13559                id: "ndd.Notepad--".to_owned(),
13560                name: "Notepad--".to_owned(),
13561                moniker: None,
13562                version: Some("1.0.0".to_owned()),
13563                channel: None,
13564                match_criteria: None,
13565            },
13566            SearchMatch {
13567                source_name: "winget".to_owned(),
13568                source_kind: SourceKind::PreIndexed,
13569                id: "Notepad++.Notepad++".to_owned(),
13570                name: "Notepad++".to_owned(),
13571                moniker: None,
13572                version: Some("8.9.5".to_owned()),
13573                channel: None,
13574                match_criteria: None,
13575            },
13576        ];
13577
13578        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13579        assert_eq!(correlated.id, "Notepad++.Notepad++");
13580    }
13581
13582    #[test]
13583    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
13584        // Without the prefix bonus, the loose substring match picked up `Studio`
13585        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
13586        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
13587        // sort order favored the alphabetically-later candidate.
13588        let installed = InstalledPackage {
13589            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
13590            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
13591            installed_version: "40.10.18029".to_owned(),
13592            publisher: None,
13593            scope: Some("Machine".to_owned()),
13594            installer_category: Some("msi".to_owned()),
13595            install_location: None,
13596            package_family_names: Vec::new(),
13597            product_codes: Vec::new(),
13598            upgrade_codes: Vec::new(),
13599            correlated: None,
13600            winget_package_identifier: None,
13601            winget_source_identifier: None,
13602            installed_version_canonical: false,
13603            correlated_requires_explicit_upgrade: false,
13604            correlated_lacks_compatible_installer: false,
13605        };
13606        let candidates = vec![
13607            SearchMatch {
13608                source_name: "winget".to_owned(),
13609                source_kind: SourceKind::PreIndexed,
13610                id: "ZeroBrane.Studio".to_owned(),
13611                name: "Studio".to_owned(),
13612                moniker: None,
13613                version: Some("1.0".to_owned()),
13614                channel: None,
13615                match_criteria: None,
13616            },
13617            SearchMatch {
13618                source_name: "winget".to_owned(),
13619                source_kind: SourceKind::PreIndexed,
13620                id: "BrickLink.Studio".to_owned(),
13621                name: "Studio".to_owned(),
13622                moniker: None,
13623                version: Some("1.0".to_owned()),
13624                channel: None,
13625                match_criteria: None,
13626            },
13627            SearchMatch {
13628                source_name: "winget".to_owned(),
13629                source_kind: SourceKind::PreIndexed,
13630                id: "Microsoft.DotNet.SDK.10".to_owned(),
13631                name: "Microsoft .NET SDK 10.0".to_owned(),
13632                moniker: None,
13633                version: Some("10.0.204".to_owned()),
13634                channel: None,
13635                match_criteria: None,
13636            },
13637        ];
13638
13639        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13640        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
13641    }
13642
13643    #[test]
13644    fn msix_packages_do_not_correlate_via_name() {
13645        // MSIX correlation must go through the v2 index's `pfns2` table — name
13646        // fallback is wrong because two MSIX packages can legitimately share a
13647        // display name without sharing identity (`Microsoft Edge Stable` MSIX
13648        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
13649        // the catalog Inno installer). Even when the names match exactly the
13650        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
13651        // PFN hit; that lookup happens in `correlate_installed_via_index`.
13652        let installed = InstalledPackage {
13653            name: "Microsoft Teams".to_owned(),
13654            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13655            installed_version: "25290.205.4069.4894".to_owned(),
13656            publisher: None,
13657            scope: Some("User".to_owned()),
13658            installer_category: Some("msix".to_owned()),
13659            install_location: None,
13660            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13661            product_codes: Vec::new(),
13662            upgrade_codes: Vec::new(),
13663            correlated: None,
13664            winget_package_identifier: None,
13665            winget_source_identifier: None,
13666            installed_version_canonical: false,
13667            correlated_requires_explicit_upgrade: false,
13668            correlated_lacks_compatible_installer: false,
13669        };
13670        let candidates = vec![SearchMatch {
13671            source_name: "winget".to_owned(),
13672            source_kind: SourceKind::PreIndexed,
13673            id: "Microsoft.Teams".to_owned(),
13674            name: "Microsoft Teams".to_owned(),
13675            moniker: None,
13676            version: Some("26106.1906.4665.7308".to_owned()),
13677            channel: None,
13678            match_criteria: None,
13679        }];
13680
13681        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13682    }
13683
13684    #[test]
13685    fn name_fallback_refuses_ambiguous_winners() {
13686        // The user has Git installed (publisher "The Git Development Community"
13687        // — but publisher isn't in the scoring function). Two catalog packages
13688        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
13689        // publisher disambiguation they score identically; winget refuses to
13690        // correlate (the install lists with empty Source). pinget must do the
13691        // same to avoid manufacturing an upgrade against the wrong catalog
13692        // package.
13693        let installed = InstalledPackage {
13694            name: "Git".to_owned(),
13695            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
13696            installed_version: "2.53.0".to_owned(),
13697            publisher: Some("The Git Development Community".to_owned()),
13698            scope: Some("Machine".to_owned()),
13699            installer_category: Some("exe".to_owned()),
13700            install_location: None,
13701            package_family_names: Vec::new(),
13702            product_codes: Vec::new(),
13703            upgrade_codes: Vec::new(),
13704            correlated: None,
13705            winget_package_identifier: None,
13706            winget_source_identifier: None,
13707            installed_version_canonical: false,
13708            correlated_requires_explicit_upgrade: false,
13709            correlated_lacks_compatible_installer: false,
13710        };
13711        let candidates = vec![
13712            SearchMatch {
13713                source_name: "winget".to_owned(),
13714                source_kind: SourceKind::PreIndexed,
13715                id: "Git.Git".to_owned(),
13716                name: "Git".to_owned(),
13717                moniker: None,
13718                version: Some("2.54.0".to_owned()),
13719                channel: None,
13720                match_criteria: None,
13721            },
13722            SearchMatch {
13723                source_name: "winget".to_owned(),
13724                source_kind: SourceKind::PreIndexed,
13725                id: "Microsoft.Git".to_owned(),
13726                name: "Git".to_owned(),
13727                moniker: None,
13728                version: Some("2.53.0.0.7".to_owned()),
13729                channel: None,
13730                match_criteria: None,
13731            },
13732        ];
13733
13734        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13735    }
13736
13737    #[test]
13738    fn msix_with_resource_string_name_does_not_correlate() {
13739        // Some MSIX entries have unresolved resource-string display names
13740        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
13741        // unrelated catalog package via the loose substring rule.
13742        let installed = InstalledPackage {
13743            name: "ms-resource:appDisplayName".to_owned(),
13744            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
13745            installed_version: "1.28.239.0".to_owned(),
13746            publisher: None,
13747            scope: Some("User".to_owned()),
13748            installer_category: Some("msix".to_owned()),
13749            install_location: None,
13750            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
13751            product_codes: Vec::new(),
13752            upgrade_codes: Vec::new(),
13753            correlated: None,
13754            winget_package_identifier: None,
13755            winget_source_identifier: None,
13756            installed_version_canonical: false,
13757            correlated_requires_explicit_upgrade: false,
13758            correlated_lacks_compatible_installer: false,
13759        };
13760        let candidates = vec![SearchMatch {
13761            source_name: "winget".to_owned(),
13762            source_kind: SourceKind::PreIndexed,
13763            id: "Microsoft.AppInstaller".to_owned(),
13764            name: "App Installer".to_owned(),
13765            moniker: None,
13766            version: Some("1.28.240.0".to_owned()),
13767            channel: None,
13768            match_criteria: None,
13769        }];
13770
13771        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13772    }
13773
13774    #[test]
13775    fn strict_list_correlation_avoids_short_substring_matches() {
13776        let installed = InstalledPackage {
13777            name: "GitHub".to_owned(),
13778            local_id: r"ARP\User\X64\GitHub".to_owned(),
13779            installed_version: "1.0.0".to_owned(),
13780            publisher: None,
13781            scope: Some("User".to_owned()),
13782            installer_category: Some("exe".to_owned()),
13783            install_location: None,
13784            package_family_names: Vec::new(),
13785            product_codes: Vec::new(),
13786            upgrade_codes: Vec::new(),
13787            correlated: None,
13788            winget_package_identifier: None,
13789            winget_source_identifier: None,
13790            installed_version_canonical: false,
13791            correlated_requires_explicit_upgrade: false,
13792            correlated_lacks_compatible_installer: false,
13793        };
13794        let candidates = vec![SearchMatch {
13795            source_name: "winget".to_owned(),
13796            source_kind: SourceKind::PreIndexed,
13797            id: "Git.Git.PreRelease".to_owned(),
13798            name: "Git".to_owned(),
13799            moniker: None,
13800            version: Some("2.54.0".to_owned()),
13801            channel: None,
13802            match_criteria: None,
13803        }];
13804
13805        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
13806    }
13807
13808    #[test]
13809    fn detects_available_upgrade_from_correlated_package() {
13810        let package = InstalledPackage {
13811            name: "AzCopy v10".to_owned(),
13812            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
13813            installed_version: "10.32.2".to_owned(),
13814            publisher: None,
13815            scope: Some("Machine".to_owned()),
13816            installer_category: Some("exe".to_owned()),
13817            install_location: None,
13818            package_family_names: Vec::new(),
13819            product_codes: Vec::new(),
13820            upgrade_codes: Vec::new(),
13821            correlated: Some(SearchMatch {
13822                source_name: "winget".to_owned(),
13823                source_kind: SourceKind::PreIndexed,
13824                id: "Microsoft.Azure.AZCopy.10".to_owned(),
13825                name: "AzCopy v10".to_owned(),
13826                moniker: None,
13827                version: Some("10.32.3".to_owned()),
13828                channel: None,
13829                match_criteria: None,
13830            }),
13831            winget_package_identifier: None,
13832            winget_source_identifier: None,
13833            installed_version_canonical: false,
13834            correlated_requires_explicit_upgrade: false,
13835            correlated_lacks_compatible_installer: false,
13836        };
13837
13838        assert!(installed_package_has_upgrade(&package));
13839    }
13840
13841    #[test]
13842    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
13843        let package = InstalledPackage {
13844            name: "Example Tool".to_owned(),
13845            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
13846            installed_version: "Unknown".to_owned(),
13847            publisher: None,
13848            scope: Some("User".to_owned()),
13849            installer_category: Some("exe".to_owned()),
13850            install_location: None,
13851            package_family_names: Vec::new(),
13852            product_codes: Vec::new(),
13853            upgrade_codes: Vec::new(),
13854            correlated: Some(SearchMatch {
13855                source_name: "winget".to_owned(),
13856                source_kind: SourceKind::PreIndexed,
13857                id: "Contoso.ExampleTool".to_owned(),
13858                name: "Example Tool".to_owned(),
13859                moniker: None,
13860                version: Some("2.0.0".to_owned()),
13861                channel: None,
13862                match_criteria: None,
13863            }),
13864            winget_package_identifier: None,
13865            winget_source_identifier: None,
13866            installed_version_canonical: false,
13867            correlated_requires_explicit_upgrade: false,
13868            correlated_lacks_compatible_installer: false,
13869        };
13870        let query = ListQuery {
13871            upgrade_only: true,
13872            include_unknown: true,
13873            ..Default::default()
13874        };
13875
13876        assert!(installed_package_matches_upgrade_filter(&package, &query));
13877        assert_eq!(
13878            list_match_from_installed(package, &[]).available_version.as_deref(),
13879            Some("2.0.0")
13880        );
13881    }
13882
13883    #[test]
13884    fn create_repair_list_query_includes_installed_selectors() {
13885        let request = RepairRequest {
13886            query: PackageQuery {
13887                query: Some("powertoys".to_owned()),
13888                id: Some("Microsoft.PowerToys".to_owned()),
13889                name: Some("PowerToys".to_owned()),
13890                moniker: Some("powertoys".to_owned()),
13891                source: Some("winget".to_owned()),
13892                exact: true,
13893                version: Some("0.98.1".to_owned()),
13894                install_scope: Some("Machine".to_owned()),
13895                ..PackageQuery::default()
13896            },
13897            manifest_path: None,
13898            product_code: Some("{GUID}".to_owned()),
13899            mode: InstallerMode::Silent,
13900            log_path: Some(PathBuf::from("repair.log")),
13901            accept_package_agreements: true,
13902            force: true,
13903            ignore_security_hash: true,
13904        };
13905
13906        let query = Repository::create_repair_list_query(&request);
13907        assert_eq!(query.query.as_deref(), Some("powertoys"));
13908        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
13909        assert_eq!(query.name.as_deref(), Some("PowerToys"));
13910        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
13911        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
13912        assert_eq!(query.version.as_deref(), Some("0.98.1"));
13913        assert_eq!(query.source.as_deref(), Some("winget"));
13914        assert!(query.exact);
13915        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
13916        assert_eq!(query.count, Some(100));
13917    }
13918
13919    #[test]
13920    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
13921        let request = RepairRequest {
13922            query: PackageQuery {
13923                name: Some("PowerToys".to_owned()),
13924                source: Some("winget".to_owned()),
13925                locale: Some("en-US".to_owned()),
13926                installer_architecture: Some("x64".to_owned()),
13927                install_scope: Some("Machine".to_owned()),
13928                ..PackageQuery::default()
13929            },
13930            manifest_path: None,
13931            product_code: None,
13932            mode: InstallerMode::SilentWithProgress,
13933            log_path: Some(PathBuf::from("repair.log")),
13934            accept_package_agreements: true,
13935            force: false,
13936            ignore_security_hash: true,
13937        };
13938        let installed = ListMatch {
13939            name: "PowerToys".to_owned(),
13940            id: "Microsoft.PowerToys".to_owned(),
13941            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
13942            installed_version: "0.98.1".to_owned(),
13943            available_version: Some("0.98.2".to_owned()),
13944            source_name: Some("winget".to_owned()),
13945            publisher: None,
13946            scope: Some("Machine".to_owned()),
13947            installer_category: Some("exe".to_owned()),
13948            install_location: None,
13949            package_family_names: Vec::new(),
13950            product_codes: Vec::new(),
13951            upgrade_codes: Vec::new(),
13952        };
13953
13954        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
13955        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
13956        assert!(install_request.query.query.is_none());
13957        assert!(install_request.query.name.is_none());
13958        assert!(install_request.query.moniker.is_none());
13959        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
13960        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
13961        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
13962        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
13963        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
13964        assert!(install_request.query.exact);
13965        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
13966        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
13967        assert!(install_request.accept_package_agreements);
13968        assert!(install_request.force);
13969        assert!(install_request.ignore_security_hash);
13970    }
13971
13972    #[test]
13973    fn source_scoped_pin_operations_round_trip() {
13974        let app_root = temp_app_root("pins");
13975        let result = (|| -> Result<()> {
13976            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
13977            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
13978            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
13979            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
13980
13981            assert_eq!(repository.list_pins(None)?.len(), 3);
13982            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
13983            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
13984
13985            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
13986            assert_eq!(repository.list_pins(None)?.len(), 2);
13987            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
13988
13989            repository.reset_pins(Some("test"))?;
13990            let remaining = repository.list_pins(None)?;
13991            assert_eq!(remaining.len(), 1);
13992            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
13993
13994            repository.reset_pins(None)?;
13995            assert!(repository.list_pins(None)?.is_empty());
13996            Ok(())
13997        })();
13998
13999        let _ = fs::remove_dir_all(&app_root);
14000        result.expect("pin round trip");
14001    }
14002
14003    #[test]
14004    fn pin_operations_work_with_packaged_pin_schema() {
14005        let app_root = temp_app_root("pins_packaged_schema");
14006        let result = (|| -> Result<()> {
14007            let db_path = pins_db_path(&app_root);
14008            if let Some(parent) = db_path.parent() {
14009                fs::create_dir_all(parent)?;
14010            }
14011
14012            let conn = Repository::open_sqlite_connection(db_path.clone())?;
14013            execute_batch_sql(
14014                &conn,
14015                "CREATE TABLE pin (
14016                    package_id TEXT NOT NULL,
14017                    source_id TEXT NOT NULL,
14018                    type INTEGER NOT NULL,
14019                    version TEXT NOT NULL,
14020                    PRIMARY KEY (package_id, source_id)
14021                )",
14022            )?;
14023            conn.cacheflush().context("failed to flush pin database")?;
14024            drop(conn);
14025
14026            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14027            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
14028
14029            let pins = repository.list_pins(None)?;
14030            assert_eq!(pins.len(), 1);
14031            assert_eq!(pins[0].package_id, "Contoso.Tool");
14032            assert_eq!(pins[0].version, "1.2.*");
14033            assert_eq!(pins[0].source_id, "winget");
14034            assert_eq!(pins[0].pin_type, PinType::Gating);
14035
14036            let conn = Repository::open_sqlite_connection(db_path)?;
14037            let stored_pin = query_optional_value(
14038                &conn,
14039                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
14040                vec![
14041                    SqlValue::Text("Contoso.Tool".to_owned()),
14042                    SqlValue::Text("winget".to_owned()),
14043                ],
14044                |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
14045            )?
14046            .ok_or_else(|| anyhow!("expected stored pin"))?;
14047            assert_eq!(stored_pin.0, 3);
14048            assert_eq!(stored_pin.1, "1.2.*");
14049
14050            Ok(())
14051        })();
14052
14053        let _ = fs::remove_dir_all(&app_root);
14054        result.expect("packaged pin schema round trip");
14055    }
14056
14057    #[test]
14058    fn version_pin_patterns_match_exact_and_prefix_values() {
14059        assert!(version_matches_pin_pattern("1.2.3", "*"));
14060        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
14061        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
14062        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
14063        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
14064    }
14065
14066    #[test]
14067    fn source_specific_pins_override_source_agnostic_pins() {
14068        let item = ListMatch {
14069            name: "Contoso Tool".to_owned(),
14070            id: "Contoso.Tool".to_owned(),
14071            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14072            installed_version: "1.0.0".to_owned(),
14073            available_version: Some("2.0.0".to_owned()),
14074            source_name: Some("winget".to_owned()),
14075            publisher: None,
14076            scope: None,
14077            installer_category: None,
14078            install_location: None,
14079            package_family_names: Vec::new(),
14080            product_codes: Vec::new(),
14081            upgrade_codes: Vec::new(),
14082        };
14083        let pins = vec![
14084            PinRecord {
14085                package_id: "Contoso.Tool".to_owned(),
14086                version: "*".to_owned(),
14087                source_id: String::new(),
14088                pin_type: PinType::Blocking,
14089            },
14090            PinRecord {
14091                package_id: "Contoso.Tool".to_owned(),
14092                version: "2.0.*".to_owned(),
14093                source_id: "winget".to_owned(),
14094                pin_type: PinType::Pinning,
14095            },
14096        ];
14097
14098        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
14099    }
14100
14101    #[test]
14102    fn blocking_pin_blocks_upgrade() {
14103        let item = ListMatch {
14104            name: "Contoso Tool".to_owned(),
14105            id: "Contoso.Tool".to_owned(),
14106            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14107            installed_version: "1.0.0".to_owned(),
14108            available_version: Some("2.0.0".to_owned()),
14109            source_name: Some("winget".to_owned()),
14110            publisher: None,
14111            scope: None,
14112            installer_category: None,
14113            install_location: None,
14114            package_family_names: Vec::new(),
14115            product_codes: Vec::new(),
14116            upgrade_codes: Vec::new(),
14117        };
14118        let pins = vec![PinRecord {
14119            package_id: "Contoso.Tool".to_owned(),
14120            version: "*".to_owned(),
14121            source_id: "winget".to_owned(),
14122            pin_type: PinType::Blocking,
14123        }];
14124
14125        assert!(is_upgrade_blocked_by_pin(&item, &pins));
14126    }
14127
14128    #[cfg(windows)]
14129    #[test]
14130    fn parses_msix_package_full_name_into_version_and_family() {
14131        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
14132            .expect("package metadata");
14133
14134        assert_eq!(parsed.version, "0.98.1.0");
14135        assert_eq!(parsed.resource_id, "");
14136        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
14137        assert!(!is_msix_split_resource_package(&parsed.resource_id));
14138    }
14139
14140    #[cfg(windows)]
14141    #[test]
14142    fn classifies_msix_split_resource_package() {
14143        let parsed = parse_msix_package_full_name(
14144            "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe",
14145        )
14146        .expect("package metadata");
14147
14148        assert_eq!(parsed.version, "1.97.17002.0");
14149        assert_eq!(parsed.resource_id, "split.scale-125");
14150        assert_eq!(
14151            parsed.family_name,
14152            "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe"
14153        );
14154        assert!(is_msix_split_resource_package(&parsed.resource_id));
14155    }
14156
14157    #[cfg(windows)]
14158    #[test]
14159    fn recognizes_windows_system_paths() {
14160        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
14161        assert!(!is_windows_system_path(
14162            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
14163        ));
14164    }
14165
14166    #[test]
14167    fn search_ranking_prefers_exact_name_over_tag_match() {
14168        let query = PackageQuery {
14169            query: Some("PowerToys".to_owned()),
14170            ..Default::default()
14171        };
14172        let exact_name = SearchMatch {
14173            source_name: "winget".to_owned(),
14174            source_kind: SourceKind::PreIndexed,
14175            id: "Microsoft.PowerToys".to_owned(),
14176            name: "PowerToys".to_owned(),
14177            moniker: Some("powertoys".to_owned()),
14178            version: Some("0.98.1".to_owned()),
14179            channel: None,
14180            match_criteria: Some("Moniker: powertoys".to_owned()),
14181        };
14182        let tag_match = SearchMatch {
14183            source_name: "winget".to_owned(),
14184            source_kind: SourceKind::PreIndexed,
14185            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
14186            name: "QR Codes for Command Palette".to_owned(),
14187            moniker: None,
14188            version: Some("0.4.0.0".to_owned()),
14189            channel: None,
14190            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
14191        };
14192
14193        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
14194    }
14195
14196    #[test]
14197    fn select_best_text_match_prefers_exact_tag_value() {
14198        let values = vec![
14199            "microsoft-powertoys".to_owned(),
14200            "powertoys-run".to_owned(),
14201            "powertoys".to_owned(),
14202        ];
14203
14204        assert_eq!(
14205            select_best_text_match(values, "PowerToys", false).as_deref(),
14206            Some("powertoys")
14207        );
14208    }
14209
14210    #[test]
14211    fn search_match_criteria_is_blank_for_name_match() {
14212        let query = PackageQuery {
14213            query: Some("PowerToys".to_owned()),
14214            ..Default::default()
14215        };
14216
14217        let result = infer_match_criteria(
14218            "Microsoft.PowerToys",
14219            "PowerToys",
14220            Some("powertoys"),
14221            &query,
14222            SearchSemantics::Many,
14223            |_| Ok(Some("powertoys".to_owned())),
14224            |_| Ok(None),
14225        )
14226        .expect("match criteria");
14227
14228        assert_eq!(result, None);
14229    }
14230
14231    #[test]
14232    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
14233        let query = PackageQuery {
14234            query: Some("PowerToys".to_owned()),
14235            ..Default::default()
14236        };
14237        let unknown_version = SearchMatch {
14238            source_name: "msstore".to_owned(),
14239            source_kind: SourceKind::Rest,
14240            id: "XP89DCGQ3K6VLD".to_owned(),
14241            name: "Microsoft PowerToys".to_owned(),
14242            moniker: None,
14243            version: Some("Unknown".to_owned()),
14244            channel: None,
14245            match_criteria: None,
14246        };
14247        let tag_match = SearchMatch {
14248            source_name: "winget".to_owned(),
14249            source_kind: SourceKind::PreIndexed,
14250            id: "Riri.QRCodeforCmdPal".to_owned(),
14251            name: "QR Code for CmdPal".to_owned(),
14252            moniker: None,
14253            version: Some("0.0.1.0".to_owned()),
14254            channel: None,
14255            match_criteria: Some("Tag: powertoys".to_owned()),
14256        };
14257
14258        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
14259    }
14260
14261    #[test]
14262    fn search_source_fetch_results_ignore_small_display_count() {
14263        let query = PackageQuery {
14264            count: Some(5),
14265            ..Default::default()
14266        };
14267
14268        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
14269        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
14270    }
14271
14272    #[test]
14273    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
14274        let query = PackageQuery {
14275            query: Some("PowerToys".to_owned()),
14276            ..Default::default()
14277        };
14278        let prefix_name = SearchMatch {
14279            source_name: "winget".to_owned(),
14280            source_kind: SourceKind::PreIndexed,
14281            id: "advaith.CurrencyConverterPowerToys".to_owned(),
14282            name: "PowerToys-Run-Currency-Converter".to_owned(),
14283            moniker: None,
14284            version: Some("1.5.4".to_owned()),
14285            channel: None,
14286            match_criteria: None,
14287        };
14288        let exact_tag = SearchMatch {
14289            source_name: "winget".to_owned(),
14290            source_kind: SourceKind::PreIndexed,
14291            id: "Riri.QRCodeforCmdPal".to_owned(),
14292            name: "QR Code for CmdPal".to_owned(),
14293            moniker: None,
14294            version: Some("0.0.1.0".to_owned()),
14295            channel: None,
14296            match_criteria: Some("Tag: powertoys".to_owned()),
14297        };
14298
14299        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
14300    }
14301
14302    #[test]
14303    fn list_sort_prefers_main_package_then_sparse_app() {
14304        let main = InstalledPackage {
14305            name: "PowerToys (Preview) x64".to_owned(),
14306            local_id: r"ARP\User\X64\PowerToys".to_owned(),
14307            installed_version: "0.98.1".to_owned(),
14308            publisher: None,
14309            scope: Some("User".to_owned()),
14310            installer_category: Some("exe".to_owned()),
14311            install_location: None,
14312            package_family_names: Vec::new(),
14313            product_codes: Vec::new(),
14314            upgrade_codes: Vec::new(),
14315            correlated: None,
14316            winget_package_identifier: None,
14317            winget_source_identifier: None,
14318            installed_version_canonical: false,
14319            correlated_requires_explicit_upgrade: false,
14320            correlated_lacks_compatible_installer: false,
14321        };
14322        let sparse = InstalledPackage {
14323            name: "PowerToys.SparseApp".to_owned(),
14324            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14325            installed_version: "0.98.1.0".to_owned(),
14326            publisher: None,
14327            scope: Some("User".to_owned()),
14328            installer_category: Some("msix".to_owned()),
14329            install_location: None,
14330            package_family_names: Vec::new(),
14331            product_codes: Vec::new(),
14332            upgrade_codes: Vec::new(),
14333            correlated: None,
14334            winget_package_identifier: None,
14335            winget_source_identifier: None,
14336            installed_version_canonical: false,
14337            correlated_requires_explicit_upgrade: false,
14338            correlated_lacks_compatible_installer: false,
14339        };
14340        let extension = InstalledPackage {
14341            name: "PowerToys FileLocksmith Context Menu".to_owned(),
14342            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14343            installed_version: "0.98.1.0".to_owned(),
14344            publisher: None,
14345            scope: Some("User".to_owned()),
14346            installer_category: Some("msix".to_owned()),
14347            install_location: None,
14348            package_family_names: Vec::new(),
14349            product_codes: Vec::new(),
14350            upgrade_codes: Vec::new(),
14351            correlated: None,
14352            winget_package_identifier: None,
14353            winget_source_identifier: None,
14354            installed_version_canonical: false,
14355            correlated_requires_explicit_upgrade: false,
14356            correlated_lacks_compatible_installer: false,
14357        };
14358
14359        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
14360        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
14361    }
14362
14363    #[test]
14364    #[cfg(windows)]
14365    fn arp_subkey_name_extracts_registry_subkey() {
14366        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
14367        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
14368    }
14369
14370    #[test]
14371    #[cfg(windows)]
14372    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
14373        let installed = ListMatch {
14374            name: "ShareX".to_owned(),
14375            id: "ShareX.ShareX".to_owned(),
14376            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
14377            installed_version: "19.0.2".to_owned(),
14378            available_version: None,
14379            source_name: Some("winget".to_owned()),
14380            publisher: Some("ShareX Team".to_owned()),
14381            scope: Some("Machine".to_owned()),
14382            installer_category: Some("exe".to_owned()),
14383            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
14384            package_family_names: Vec::new(),
14385            product_codes: Vec::new(),
14386            upgrade_codes: Vec::new(),
14387        };
14388
14389        assert!(registry_entry_matches_installed_package(
14390            "ShareX", "ShareX", None, &installed
14391        ));
14392        assert!(!registry_entry_matches_installed_package(
14393            "ShareX.ShareX",
14394            "ShareX.ShareX",
14395            None,
14396            &installed
14397        ));
14398    }
14399
14400    #[test]
14401    #[cfg(windows)]
14402    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
14403        assert_eq!(
14404            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
14405            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
14406        );
14407        assert_eq!(
14408            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
14409            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
14410        );
14411        assert_eq!(
14412            r#""C:\Program Files\ShareX\unins000.exe""#,
14413            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
14414        );
14415        assert_eq!(
14416            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14417            build_uninstall_command(
14418                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14419                true,
14420                false,
14421            )
14422        );
14423    }
14424
14425    #[test]
14426    #[cfg(windows)]
14427    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
14428        let installed = ListMatch {
14429            name: "lazygit".to_owned(),
14430            id: "JesseDuffield.lazygit".to_owned(),
14431            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
14432            installed_version: "0.61.1".to_owned(),
14433            available_version: None,
14434            source_name: Some("winget".to_owned()),
14435            publisher: Some("Jesse Duffield".to_owned()),
14436            scope: Some("User".to_owned()),
14437            installer_category: Some("exe".to_owned()),
14438            install_location: Some(
14439                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
14440            ),
14441            package_family_names: Vec::new(),
14442            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
14443            upgrade_codes: Vec::new(),
14444        };
14445        let mut request = UninstallRequest::new(PackageQuery {
14446            id: Some("JesseDuffield.lazygit".to_owned()),
14447            install_scope: Some("user".to_owned()),
14448            ..PackageQuery::default()
14449        });
14450        request.mode = InstallerMode::Silent;
14451
14452        assert_eq!(
14453            Some(vec![
14454                "uninstall".to_owned(),
14455                "--id".to_owned(),
14456                "JesseDuffield.lazygit".to_owned(),
14457                "--exact".to_owned(),
14458                "--disable-interactivity".to_owned(),
14459                "--source".to_owned(),
14460                "winget".to_owned(),
14461                "--scope".to_owned(),
14462                "user".to_owned(),
14463                "--silent".to_owned(),
14464            ]),
14465            build_winget_uninstall_arguments(&installed, &request)
14466        );
14467
14468        assert_eq!(
14469            Some(vec![
14470                "uninstall".to_owned(),
14471                "--id".to_owned(),
14472                "JesseDuffield.lazygit".to_owned(),
14473                "--exact".to_owned(),
14474                "--disable-interactivity".to_owned(),
14475                "--source".to_owned(),
14476                "winget".to_owned(),
14477                "--silent".to_owned(),
14478            ]),
14479            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
14480        );
14481    }
14482
14483    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
14484        PackageVersionDataEntry {
14485            version: version.to_owned(),
14486            manifest_relative_path: format!("manifests/x/{version}"),
14487            manifest_hash: "00".repeat(32),
14488            arp_min_version: arp_min.map(str::to_owned),
14489            arp_max_version: arp_max.map(str::to_owned),
14490        }
14491    }
14492
14493    fn v1_version(version: &str, channel: &str) -> V1VersionRow {
14494        V1VersionRow {
14495            version: version.to_owned(),
14496            channel: channel.to_owned(),
14497            pathpart_id: 0,
14498            manifest_hash: None,
14499        }
14500    }
14501
14502    #[derive(Clone)]
14503    struct TestHttpResponse {
14504        status: u16,
14505        body: Vec<u8>,
14506        source_version: Option<String>,
14507        etag: Option<String>,
14508        last_modified: Option<String>,
14509    }
14510
14511    struct TestHttpServer {
14512        addr: SocketAddr,
14513        routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14514        counts: Arc<Mutex<HashMap<String, usize>>>,
14515        running: Arc<AtomicBool>,
14516        handle: Option<JoinHandle<()>>,
14517    }
14518
14519    impl TestHttpServer {
14520        fn start() -> Self {
14521            let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
14522            let addr = listener.local_addr().expect("test HTTP server address");
14523            let routes = Arc::new(Mutex::new(HashMap::new()));
14524            let counts = Arc::new(Mutex::new(HashMap::new()));
14525            let running = Arc::new(AtomicBool::new(true));
14526            let thread_routes = Arc::clone(&routes);
14527            let thread_counts = Arc::clone(&counts);
14528            let thread_running = Arc::clone(&running);
14529            let handle = thread::spawn(move || {
14530                while thread_running.load(AtomicOrdering::SeqCst) {
14531                    let Ok((stream, _)) = listener.accept() else {
14532                        continue;
14533                    };
14534                    handle_test_http_request(stream, &thread_routes, &thread_counts);
14535                }
14536            });
14537
14538            Self {
14539                addr,
14540                routes,
14541                counts,
14542                running,
14543                handle: Some(handle),
14544            }
14545        }
14546
14547        fn url(&self) -> String {
14548            format!("http://{}", self.addr)
14549        }
14550
14551        fn set_bytes(&self, path: &str, body: Vec<u8>) {
14552            self.set_response(
14553                path,
14554                TestHttpResponse {
14555                    status: 200,
14556                    body,
14557                    source_version: None,
14558                    etag: None,
14559                    last_modified: None,
14560                },
14561            );
14562        }
14563
14564        fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
14565            self.set_source_package_with_validators(body, source_version, None, None);
14566        }
14567
14568        fn set_source_package_with_validators(
14569            &self,
14570            body: Vec<u8>,
14571            source_version: &str,
14572            etag: Option<&str>,
14573            last_modified: Option<&str>,
14574        ) {
14575            self.set_response(
14576                "/source.msix",
14577                TestHttpResponse {
14578                    status: 200,
14579                    body,
14580                    source_version: Some(source_version.to_owned()),
14581                    etag: etag.map(str::to_owned),
14582                    last_modified: last_modified.map(str::to_owned),
14583                },
14584            );
14585        }
14586
14587        fn set_status(&self, path: &str, status: u16) {
14588            self.set_response(
14589                path,
14590                TestHttpResponse {
14591                    status,
14592                    body: Vec::new(),
14593                    source_version: None,
14594                    etag: None,
14595                    last_modified: None,
14596                },
14597            );
14598        }
14599
14600        fn set_response(&self, path: &str, response: TestHttpResponse) {
14601            self.routes
14602                .lock()
14603                .expect("test HTTP routes")
14604                .insert(path.to_owned(), response);
14605        }
14606
14607        fn request_count(&self, path: &str) -> usize {
14608            self.counts
14609                .lock()
14610                .expect("test HTTP counts")
14611                .get(path)
14612                .copied()
14613                .unwrap_or_default()
14614        }
14615    }
14616
14617    impl Drop for TestHttpServer {
14618        fn drop(&mut self) {
14619            self.running.store(false, AtomicOrdering::SeqCst);
14620            let _ = TcpStream::connect(self.addr);
14621            if let Some(handle) = self.handle.take() {
14622                let _ = handle.join();
14623            }
14624        }
14625    }
14626
14627    fn handle_test_http_request(
14628        mut stream: TcpStream,
14629        routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14630        counts: &Arc<Mutex<HashMap<String, usize>>>,
14631    ) {
14632        let mut buffer = [0u8; 2048];
14633        let read = stream.read(&mut buffer).unwrap_or(0);
14634        let request = String::from_utf8_lossy(&buffer[..read]);
14635        let path = request
14636            .lines()
14637            .next()
14638            .and_then(|line| line.split_whitespace().nth(1))
14639            .unwrap_or("/");
14640        *counts
14641            .lock()
14642            .expect("test HTTP counts")
14643            .entry(path.to_owned())
14644            .or_default() += 1;
14645        let response = routes.lock().expect("test HTTP routes").get(path).cloned();
14646        let response = response.unwrap_or(TestHttpResponse {
14647            status: 404,
14648            body: Vec::new(),
14649            source_version: None,
14650            etag: None,
14651            last_modified: None,
14652        });
14653        let not_modified = response.status == 200
14654            && (response.etag.as_deref().is_some_and(|etag| {
14655                request_header_value(&request, "If-None-Match")
14656                    .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
14657            }) || response.last_modified.as_deref().is_some_and(|last_modified| {
14658                request_header_value(&request, "If-Modified-Since")
14659                    .is_some_and(|value| value.eq_ignore_ascii_case(last_modified))
14660            }));
14661        let status = if not_modified { 304 } else { response.status };
14662        let body = if not_modified {
14663            &[][..]
14664        } else {
14665            response.body.as_slice()
14666        };
14667        let reason = match status {
14668            200 => "OK",
14669            304 => "Not Modified",
14670            _ => "Not Found",
14671        };
14672        let mut headers = format!(
14673            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
14674            status,
14675            reason,
14676            body.len()
14677        );
14678        if let Some(source_version) = response.source_version {
14679            headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
14680        }
14681        if let Some(etag) = response.etag {
14682            headers.push_str(&format!("ETag: {etag}\r\n"));
14683        }
14684        if let Some(last_modified) = response.last_modified {
14685            headers.push_str(&format!("Last-Modified: {last_modified}\r\n"));
14686        }
14687        headers.push_str("\r\n");
14688        stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
14689        stream.write_all(body).expect("write test HTTP body");
14690    }
14691
14692    fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
14693        request.lines().find_map(|line| {
14694            let (header_name, value) = line.split_once(':')?;
14695            header_name.eq_ignore_ascii_case(name).then(|| value.trim())
14696        })
14697    }
14698
14699    fn test_source_record(source_url: String) -> SourceRecord {
14700        SourceRecord {
14701            name: "test-winget".to_owned(),
14702            kind: SourceKind::PreIndexed,
14703            arg: source_url,
14704            identifier: "Test.Source".to_owned(),
14705            trust_level: "Trusted".to_owned(),
14706            explicit: false,
14707            priority: 0,
14708            last_update: Some(Utc::now()),
14709            source_version: None,
14710            etag: None,
14711            last_modified: None,
14712        }
14713    }
14714
14715    fn open_test_preindexed_repository(
14716        app_root: &Path,
14717        source: &SourceRecord,
14718        interval: Option<Duration>,
14719    ) -> Repository {
14720        fs::create_dir_all(app_root).expect("create app root");
14721        save_store(
14722            app_root,
14723            &SourceStore {
14724                sources: vec![source.clone()],
14725            },
14726        )
14727        .expect("save source store");
14728        Repository::open_with_options(
14729            RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
14730        )
14731        .expect("open repository")
14732    }
14733
14734    fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
14735        let index_path = preindexed_index_path(app_root, source);
14736        if let Some(parent) = index_path.parent() {
14737            fs::create_dir_all(parent).expect("create source state directory");
14738        }
14739        fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
14740    }
14741
14742    #[test]
14743    fn replace_file_failure_keeps_existing_target() {
14744        let root = temp_app_root("replace_file_failure");
14745        fs::create_dir_all(&root).expect("create replace root");
14746        let target = root.join("index.db");
14747        let missing_source = root.join("missing.tmp");
14748        fs::write(&target, b"old-index").expect("write existing target");
14749
14750        let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
14751
14752        assert!(format!("{error:#}").contains("failed to replace"));
14753        assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
14754        let _ = fs::remove_dir_all(root);
14755    }
14756
14757    #[cfg(windows)]
14758    #[test]
14759    fn replace_file_succeeds_while_target_is_open() {
14760        use std::os::windows::fs::OpenOptionsExt;
14761        // FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE — the sharing
14762        // mode SQLite/Turso uses. An atomic MOVEFILE_REPLACE_EXISTING is denied
14763        // while this handle is open, so the stage-aside fallback must take over.
14764        const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
14765
14766        let root = temp_app_root("replace_file_open_target");
14767        fs::create_dir_all(&root).expect("create replace root");
14768        let target = root.join("index.db");
14769        let source = root.join("index.new.tmp");
14770        fs::write(&target, b"old-index").expect("write existing target");
14771        fs::write(&source, b"new-index").expect("write replacement");
14772
14773        let held = OpenOptions::new()
14774            .read(true)
14775            .share_mode(SHARE_ALL)
14776            .open(&target)
14777            .expect("open target with share-delete");
14778
14779        replace_file(&source, &target).expect("stage-aside replace should succeed");
14780
14781        assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
14782        drop(held);
14783        let _ = fs::remove_dir_all(root);
14784    }
14785
14786    fn make_source_package(versions: &[&str]) -> Vec<u8> {
14787        let cursor = Cursor::new(Vec::new());
14788        let mut archive = zip::ZipWriter::new(cursor);
14789        let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
14790        archive
14791            .start_file("Public/index.db", options)
14792            .expect("start index entry");
14793        archive
14794            .write_all(&make_v1_index_bytes(versions))
14795            .expect("write index entry");
14796        archive.finish().expect("finish source package").into_inner()
14797    }
14798
14799    fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
14800        let root = temp_app_root("preindexed_index_bytes");
14801        fs::create_dir_all(&root).expect("create index temp root");
14802        let db_path = root.join("index.db");
14803        let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
14804        let mut sql = String::from(
14805            "CREATE TABLE ids (id TEXT);
14806             CREATE TABLE names (name TEXT);
14807             CREATE TABLE monikers (moniker TEXT);
14808             CREATE TABLE versions (version TEXT);
14809             CREATE TABLE channels (channel TEXT);
14810             CREATE TABLE pathparts (parent INT64, pathpart TEXT);
14811             CREATE TABLE manifest (
14812                 id INT64,
14813                 name INT64,
14814                 moniker INT64,
14815                 version INT64,
14816                 channel INT64,
14817                 pathpart INT64
14818             );
14819             INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
14820             INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
14821             INSERT INTO channels(rowid, channel) VALUES (1, '');
14822             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
14823             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
14824        );
14825        for (index, version) in versions.iter().enumerate() {
14826            let rowid = index + 1;
14827            sql.push_str(&format!(
14828                "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
14829                 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
14830            ));
14831        }
14832        execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
14833        let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
14834            .expect("checkpoint preindexed V1 database");
14835        connection.cacheflush().expect("flush preindexed V1 database");
14836        drop(connection);
14837        let bytes = fs::read(&db_path).expect("read index bytes");
14838        let _ = fs::remove_dir_all(root);
14839        bytes
14840    }
14841
14842    fn test_manifest_yaml(version: &str) -> Vec<u8> {
14843        format!(
14844            "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
14845        )
14846        .into_bytes()
14847    }
14848
14849    fn show_test_package(version: Option<&str>) -> PackageQuery {
14850        PackageQuery {
14851            id: Some("Test.Package".to_owned()),
14852            source: Some("test-winget".to_owned()),
14853            exact: true,
14854            version: version.map(str::to_owned),
14855            ..PackageQuery::default()
14856        }
14857    }
14858
14859    #[test]
14860    fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
14861        let app_root = temp_app_root("preindexed_explicit_refresh");
14862        let server = TestHttpServer::start();
14863        server.set_status("/source2.msix", 404);
14864        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
14865        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14866        let source = test_source_record(server.url());
14867        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14868        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14869
14870        let result = repository
14871            .show(&show_test_package(Some("2.0.0")))
14872            .expect("show after refresh");
14873
14874        assert_eq!(result.manifest.version, "2.0.0");
14875        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
14876        let _ = fs::remove_dir_all(app_root);
14877    }
14878
14879    #[test]
14880    fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
14881        let app_root = temp_app_root("preindexed_explicit_still_missing");
14882        let server = TestHttpServer::start();
14883        server.set_status("/source2.msix", 404);
14884        server.set_source_package(make_source_package(&["1.0.0"]), "same");
14885        let source = test_source_record(server.url());
14886        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14887        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14888
14889        let error = repository
14890            .show(&show_test_package(Some("2.0.0")))
14891            .expect_err("missing explicit version must fail");
14892
14893        assert!(format!("{error:#}").contains("2.0.0"));
14894        let _ = fs::remove_dir_all(app_root);
14895    }
14896
14897    #[test]
14898    fn preindexed_stale_index_refreshes_for_latest_requests() {
14899        let app_root = temp_app_root("preindexed_ttl_refresh");
14900        let server = TestHttpServer::start();
14901        server.set_status("/source2.msix", 404);
14902        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14903        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14904        let mut source = test_source_record(server.url());
14905        source.last_update = Some(Utc::now() - Duration::days(2));
14906        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14907        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14908
14909        let result = repository
14910            .show(&show_test_package(None))
14911            .expect("show latest after TTL refresh");
14912
14913        assert_eq!(result.manifest.version, "2.0.0");
14914        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
14915        let _ = fs::remove_dir_all(app_root);
14916    }
14917
14918    #[test]
14919    fn preindexed_stale_index_not_modified_keeps_local_index() {
14920        let app_root = temp_app_root("preindexed_ttl_not_modified");
14921        let server = TestHttpServer::start();
14922        server.set_status("/source2.msix", 404);
14923        server.set_source_package_with_validators(
14924            make_source_package(&["1.0.0", "2.0.0"]),
14925            "fresh",
14926            Some("\"same-index\""),
14927            Some("Wed, 03 Jun 2026 12:00:00 GMT"),
14928        );
14929        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14930        let mut source = test_source_record(server.url());
14931        source.last_update = Some(Utc::now() - Duration::days(2));
14932        source.etag = Some("\"same-index\"".to_owned());
14933        source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
14934        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14935        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14936
14937        let result = repository
14938            .show(&show_test_package(None))
14939            .expect("show latest from not-modified stale index");
14940
14941        let refreshed_source = &repository.list_sources()[0];
14942        assert_eq!(result.manifest.version, "1.0.0");
14943        assert_eq!(server.request_count("/source.msix"), 1);
14944        assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\""));
14945        assert_eq!(
14946            refreshed_source.last_modified.as_deref(),
14947            Some("Wed, 03 Jun 2026 12:00:00 GMT")
14948        );
14949        let _ = fs::remove_dir_all(app_root);
14950    }
14951
14952    #[test]
14953    fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
14954        let app_root = temp_app_root("preindexed_fresh_mtime");
14955        let server = TestHttpServer::start();
14956        server.set_status("/source2.msix", 404);
14957        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14958        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14959        let mut source = test_source_record(server.url());
14960        source.last_update = Some(Utc::now() - Duration::days(2));
14961        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14962        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
14963
14964        let result = repository
14965            .show(&show_test_package(None))
14966            .expect("show latest from fresh index mtime");
14967
14968        assert_eq!(result.manifest.version, "1.0.0");
14969        assert_eq!(server.request_count("/source.msix"), 0);
14970        let _ = fs::remove_dir_all(app_root);
14971    }
14972
14973    #[test]
14974    fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
14975        let app_root = temp_app_root("preindexed_ttl_fallback");
14976        let server = TestHttpServer::start();
14977        server.set_status("/source2.msix", 404);
14978        server.set_status("/source.msix", 404);
14979        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14980        let mut source = test_source_record(server.url());
14981        source.last_update = Some(Utc::now() - Duration::days(2));
14982        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14983        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14984
14985        let result = repository
14986            .show(&show_test_package(None))
14987            .expect("show latest from stale cache after refresh failure");
14988
14989        assert_eq!(result.manifest.version, "1.0.0");
14990        let _ = fs::remove_dir_all(app_root);
14991    }
14992
14993    #[test]
14994    fn explicit_source_search_failure_is_not_reported_as_no_match() {
14995        let app_root = temp_app_root("preindexed_source_search_failure");
14996        let server = TestHttpServer::start();
14997        server.set_status("/source2.msix", 404);
14998        server.set_status("/source.msix", 404);
14999        let source = test_source_record(server.url());
15000        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15001
15002        let error = repository
15003            .show(&show_test_package(None))
15004            .expect_err("source failure must surface");
15005        let message = format!("{error:#}");
15006
15007        assert!(message.contains("failed to search source 'test-winget'"));
15008        assert!(message.contains("source.msix"));
15009        assert!(!message.contains("no package matched the supplied query"));
15010        let _ = fs::remove_dir_all(app_root);
15011    }
15012
15013    #[test]
15014    fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
15015        let app_root = temp_app_root("preindexed_no_double_refresh");
15016        let server = TestHttpServer::start();
15017        server.set_status("/source2.msix", 404);
15018        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15019        let mut source = test_source_record(server.url());
15020        source.last_update = Some(Utc::now() - Duration::days(2));
15021        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15022        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15023
15024        let error = repository
15025            .show(&show_test_package(Some("3.0.0")))
15026            .expect_err("still-missing explicit version must fail after one stale refresh");
15027
15028        assert!(format!("{error:#}").contains("3.0.0"));
15029        assert_eq!(server.request_count("/source.msix"), 1);
15030        let _ = fs::remove_dir_all(app_root);
15031    }
15032
15033    #[test]
15034    fn select_v2_version_without_explicit_filters_returns_latest() {
15035        let versions = vec![
15036            version_entry("1.0.0", None, None),
15037            version_entry("1.2.0", None, None),
15038            version_entry("1.1.0", None, None),
15039        ];
15040
15041        let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
15042
15043        assert_eq!(selected.version, "1.2.0");
15044    }
15045
15046    #[test]
15047    fn select_v2_version_with_missing_requested_version_throws() {
15048        let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
15049
15050        let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
15051            .expect_err("missing requested version");
15052
15053        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15054        assert!(error.to_string().contains("9.9.9"));
15055    }
15056
15057    #[test]
15058    fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
15059        let versions = vec![version_entry("1.2.0", None, None)];
15060
15061        let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15062            .expect_err("unsupported channel request");
15063
15064        assert!(error.to_string().contains("beta"));
15065        assert!(error.to_string().contains("channel metadata"));
15066    }
15067
15068    #[test]
15069    fn select_v1_version_channel_only_returns_latest_matching_channel() {
15070        let versions = vec![
15071            v1_version("1.2.0", "stable"),
15072            v1_version("1.3.0", "stable"),
15073            v1_version("2.0.0", "preview"),
15074        ];
15075
15076        let selected =
15077            select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
15078
15079        assert_eq!(selected.version, "1.3.0");
15080    }
15081
15082    #[test]
15083    fn select_rest_version_channel_mismatch_throws() {
15084        let versions = vec![
15085            VersionKey {
15086                version: "1.2.0".to_owned(),
15087                channel: "stable".to_owned(),
15088            },
15089            VersionKey {
15090                version: "1.1.0".to_owned(),
15091                channel: "beta".to_owned(),
15092            },
15093        ];
15094
15095        let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15096            .expect_err("missing version and channel pair");
15097
15098        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15099    }
15100
15101    #[test]
15102    fn map_arp_version_returns_catalog_version_inside_range() {
15103        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
15104        // `10.1.826.23019`. compare_version on that against the catalog Version
15105        // `10.0.108` claims installed > available, so without this mapping the
15106        // upgrade was silently dropped.
15107        let entries = vec![
15108            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15109            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15110            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
15111        ];
15112
15113        assert_eq!(
15114            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
15115            Some("10.0.108")
15116        );
15117    }
15118
15119    #[test]
15120    fn map_arp_version_returns_none_when_no_range_matches() {
15121        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
15122        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
15123        // original installed version (and accept that no upgrade can be
15124        // computed) — same as winget's behavior.
15125        let entries = vec![
15126            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15127            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15128        ];
15129
15130        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
15131    }
15132
15133    #[test]
15134    fn map_arp_version_reports_less_than_latest_for_coarse_display_version() {
15135        // Snagit 2025 reports ARP DisplayVersion `25.4.1`, but the catalog's
15136        // latest ARP build is `25.4.1.10325`. winget renders this as
15137        // installed `< 25.4.1` so the latest installer remains applicable.
15138        let entries = vec![
15139            version_entry("25.4.1", Some("25.4.1.10325"), Some("25.4.1.10325")),
15140            version_entry("25.4.0", Some("25.4.0"), Some("25.4.0.8498")),
15141        ];
15142
15143        assert!(map_arp_version_to_catalog(&entries, "25.4.1").is_none());
15144        assert_eq!(
15145            less_than_latest_arp_anchored_version(&entries, "25.4.1").as_deref(),
15146            Some("< 25.4.1")
15147        );
15148        assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none());
15149    }
15150
15151    #[test]
15152    fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() {
15153        // WindowsAppRuntime packages can be serviced beyond the latest winget
15154        // catalog versionData range. winget renders those as `> latest` and
15155        // does not report an available downgrade.
15156        let entries = vec![
15157            version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")),
15158            version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")),
15159        ];
15160
15161        assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none());
15162        assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none());
15163        assert_eq!(
15164            greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(),
15165            Some("> 1.5.8")
15166        );
15167        assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none());
15168
15169        let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))];
15170        assert!(
15171            greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none()
15172        );
15173    }
15174
15175    #[test]
15176    fn map_arp_version_skips_entries_missing_arp_bounds() {
15177        // Older catalog packages predate AppsAndFeaturesEntries and have no
15178        // aMiV/aMaV. Those entries must be ignored — not silently treated as
15179        // an unbounded range.
15180        let entries = vec![
15181            version_entry("9.0.300", None, None),
15182            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
15183        ];
15184
15185        assert_eq!(
15186            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
15187            Some("9.0.117")
15188        );
15189        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
15190    }
15191
15192    #[test]
15193    fn map_arp_version_handles_inclusive_range_endpoints() {
15194        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
15195        // must still map.
15196        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
15197
15198        assert_eq!(
15199            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
15200            Some("3.14.5")
15201        );
15202        assert_eq!(
15203            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
15204            Some("3.14.5")
15205        );
15206        assert_eq!(
15207            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
15208            Some("3.14.5")
15209        );
15210    }
15211
15212    #[test]
15213    fn map_arp_version_ignores_unknown_marker() {
15214        // collect_installed_packages writes the literal "Unknown" when ARP has
15215        // no DisplayVersion. Trying to map that would falsely match the first
15216        // entry whose aMiV..aMaV string-compares to include "Unknown".
15217        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
15218
15219        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
15220        assert!(map_arp_version_to_catalog(&entries, "").is_none());
15221    }
15222
15223    #[test]
15224    fn latest_arp_anchored_skips_internal_versions() {
15225        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
15226        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
15227        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
15228        // it against an installed canonical of `1.8.6` would falsely report
15229        // an upgrade. Returns the highest anchored Version instead.
15230        let entries = vec![
15231            version_entry("8000.836.2153.0", None, None),
15232            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
15233            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
15234            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
15235        ];
15236
15237        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
15238    }
15239
15240    #[test]
15241    fn latest_arp_anchored_returns_none_without_bounds() {
15242        // App Installer's versionData has no aMiV/aMaV — every version is a
15243        // user-facing MSIX Version. Caller must fall back to
15244        // `packages.latest_version` in this case.
15245        let entries = vec![
15246            version_entry("1.28.240.0", None, None),
15247            version_entry("1.27.470.0", None, None),
15248        ];
15249
15250        assert!(latest_arp_anchored_version(&entries).is_none());
15251    }
15252
15253    #[test]
15254    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
15255        let package = InstalledPackage {
15256            name: "AzCopy v10".to_owned(),
15257            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
15258            installed_version: "10.32.2".to_owned(),
15259            publisher: Some("Microsoft".to_owned()),
15260            scope: Some("Machine".to_owned()),
15261            installer_category: Some("exe".to_owned()),
15262            install_location: None,
15263            package_family_names: Vec::new(),
15264            product_codes: Vec::new(),
15265            upgrade_codes: Vec::new(),
15266            correlated: Some(SearchMatch {
15267                source_name: "winget".to_owned(),
15268                source_kind: SourceKind::PreIndexed,
15269                id: "Microsoft.Azure.AZCopy.10".to_owned(),
15270                name: "AzCopy v10".to_owned(),
15271                moniker: None,
15272                version: Some("10.32.3".to_owned()),
15273                channel: None,
15274                match_criteria: None,
15275            }),
15276            winget_package_identifier: None,
15277            winget_source_identifier: None,
15278            installed_version_canonical: false,
15279            correlated_requires_explicit_upgrade: false,
15280            correlated_lacks_compatible_installer: false,
15281        };
15282
15283        let item = list_match_from_installed(package, &[]);
15284
15285        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
15286        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
15287        assert_eq!(item.source_name.as_deref(), Some("winget"));
15288        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
15289    }
15290
15291    #[test]
15292    fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() {
15293        let rows = vec![
15294            ListMatch {
15295                name: "Example 1.0".to_owned(),
15296                id: "Example.Tool".to_owned(),
15297                local_id: r"ARP\Machine\X64\old".to_owned(),
15298                installed_version: "1.0.0".to_owned(),
15299                available_version: Some("3.0.0".to_owned()),
15300                source_name: Some("winget".to_owned()),
15301                publisher: None,
15302                scope: None,
15303                installer_category: None,
15304                install_location: None,
15305                package_family_names: Vec::new(),
15306                product_codes: Vec::new(),
15307                upgrade_codes: Vec::new(),
15308            },
15309            ListMatch {
15310                name: "Example 2.0".to_owned(),
15311                id: "Example.Tool".to_owned(),
15312                local_id: r"ARP\Machine\X64\new".to_owned(),
15313                installed_version: "2.0.0".to_owned(),
15314                available_version: Some("3.0.0".to_owned()),
15315                source_name: Some("winget".to_owned()),
15316                publisher: None,
15317                scope: None,
15318                installer_category: None,
15319                install_location: None,
15320                package_family_names: Vec::new(),
15321                product_codes: Vec::new(),
15322                upgrade_codes: Vec::new(),
15323            },
15324            ListMatch {
15325                name: "Other".to_owned(),
15326                id: "Other.Tool".to_owned(),
15327                local_id: r"ARP\Machine\X64\other".to_owned(),
15328                installed_version: "1.0.0".to_owned(),
15329                available_version: Some("2.0.0".to_owned()),
15330                source_name: Some("winget".to_owned()),
15331                publisher: None,
15332                scope: None,
15333                installer_category: None,
15334                install_location: None,
15335                package_family_names: Vec::new(),
15336                product_codes: Vec::new(),
15337                upgrade_codes: Vec::new(),
15338            },
15339        ];
15340
15341        let result = suppress_duplicate_available_versions(rows);
15342
15343        assert_eq!(result[0].available_version, None);
15344        assert_eq!(result[1].available_version.as_deref(), Some("3.0.0"));
15345        assert_eq!(result[2].available_version.as_deref(), Some("2.0.0"));
15346    }
15347
15348    #[test]
15349    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
15350        let package = InstalledPackage {
15351            name: "Atlassian CLI".to_owned(),
15352            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15353            installed_version: "1.3.18-stable".to_owned(),
15354            publisher: Some("Atlassian".to_owned()),
15355            scope: Some("User".to_owned()),
15356            installer_category: Some("exe".to_owned()),
15357            install_location: None,
15358            package_family_names: Vec::new(),
15359            product_codes: Vec::new(),
15360            upgrade_codes: Vec::new(),
15361            correlated: Some(SearchMatch {
15362                source_name: "winget".to_owned(),
15363                source_kind: SourceKind::PreIndexed,
15364                id: "Atlassian.AtlassianCLI".to_owned(),
15365                name: "Atlassian CLI".to_owned(),
15366                moniker: None,
15367                version: Some("1.3.18-stable".to_owned()),
15368                channel: None,
15369                match_criteria: Some("ProductCode".to_owned()),
15370            }),
15371            winget_package_identifier: None,
15372            winget_source_identifier: None,
15373            installed_version_canonical: false,
15374            correlated_requires_explicit_upgrade: false,
15375            correlated_lacks_compatible_installer: false,
15376        };
15377
15378        let item = list_match_from_installed(package, &[]);
15379
15380        assert_eq!(item.id, "Atlassian.AtlassianCLI");
15381        assert_eq!(
15382            item.local_id,
15383            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
15384        );
15385        assert_ne!(item.id, item.local_id);
15386        assert_eq!(item.source_name.as_deref(), Some("winget"));
15387    }
15388
15389    #[test]
15390    fn list_match_from_installed_derives_winget_portable_identity_from_local_id() {
15391        let package = InstalledPackage {
15392            name: "FFmpeg".to_owned(),
15393            local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15394            installed_version: "8.0".to_owned(),
15395            publisher: Some("Gyan".to_owned()),
15396            scope: Some("User".to_owned()),
15397            installer_category: Some("portable".to_owned()),
15398            install_location: None,
15399            package_family_names: Vec::new(),
15400            product_codes: Vec::new(),
15401            upgrade_codes: Vec::new(),
15402            correlated: None,
15403            winget_package_identifier: None,
15404            winget_source_identifier: None,
15405            installed_version_canonical: false,
15406            correlated_requires_explicit_upgrade: false,
15407            correlated_lacks_compatible_installer: false,
15408        };
15409        let sources = vec![SourceRecord {
15410            name: "winget".to_owned(),
15411            kind: SourceKind::PreIndexed,
15412            arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
15413            identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15414            trust_level: "Trusted".to_owned(),
15415            explicit: false,
15416            priority: 0,
15417            last_update: None,
15418            source_version: None,
15419            etag: None,
15420            last_modified: None,
15421        }];
15422
15423        let item = list_match_from_installed(package, &sources);
15424
15425        assert_eq!(item.id, "Gyan.FFmpeg");
15426        assert_eq!(
15427            item.local_id,
15428            r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe"
15429        );
15430        assert_eq!(item.source_name.as_deref(), Some("winget"));
15431    }
15432
15433    #[test]
15434    fn list_match_from_installed_uses_winget_arp_identity_metadata() {
15435        let package = InstalledPackage {
15436            name: "tessl".to_owned(),
15437            local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15438            installed_version: "0.77.0".to_owned(),
15439            publisher: Some("tessl.io".to_owned()),
15440            scope: Some("User".to_owned()),
15441            installer_category: Some("portable".to_owned()),
15442            install_location: None,
15443            package_family_names: Vec::new(),
15444            product_codes: Vec::new(),
15445            upgrade_codes: Vec::new(),
15446            correlated: None,
15447            winget_package_identifier: Some("tessl.tessl".to_owned()),
15448            winget_source_identifier: Some("api.winget.pro".to_owned()),
15449            installed_version_canonical: false,
15450            correlated_requires_explicit_upgrade: false,
15451            correlated_lacks_compatible_installer: false,
15452        };
15453
15454        let item = list_match_from_installed(package, &[]);
15455
15456        assert_eq!(item.id, "tessl.tessl");
15457        assert_eq!(item.source_name.as_deref(), Some("api.winget.pro"));
15458    }
15459
15460    #[test]
15461    fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() {
15462        let package = InstalledPackage {
15463            name: "tessl".to_owned(),
15464            local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15465            installed_version: "0.77.0".to_owned(),
15466            publisher: Some("tessl.io".to_owned()),
15467            scope: Some("User".to_owned()),
15468            installer_category: Some("portable".to_owned()),
15469            install_location: None,
15470            package_family_names: Vec::new(),
15471            product_codes: Vec::new(),
15472            upgrade_codes: Vec::new(),
15473            correlated: None,
15474            winget_package_identifier: Some("tessl.tessl".to_owned()),
15475            winget_source_identifier: Some("api.winget.pro".to_owned()),
15476            installed_version_canonical: false,
15477            correlated_requires_explicit_upgrade: false,
15478            correlated_lacks_compatible_installer: false,
15479        };
15480        let sources = vec![SourceRecord {
15481            name: "winget.pro".to_owned(),
15482            kind: SourceKind::Rest,
15483            arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(),
15484            identifier: "api.winget.pro".to_owned(),
15485            trust_level: "Trusted".to_owned(),
15486            explicit: false,
15487            priority: 0,
15488            last_update: None,
15489            source_version: None,
15490            etag: None,
15491            last_modified: None,
15492        }];
15493
15494        let item = list_match_from_installed(package, &sources);
15495
15496        assert_eq!(item.id, "tessl.tessl");
15497        assert_eq!(item.source_name.as_deref(), Some("winget.pro"));
15498    }
15499
15500    #[test]
15501    fn list_match_from_installed_keeps_scanning_portable_source_identifiers() {
15502        let package = InstalledPackage {
15503            name: "Example".to_owned(),
15504            local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(),
15505            installed_version: "1.0".to_owned(),
15506            publisher: Some("Vendor".to_owned()),
15507            scope: Some("User".to_owned()),
15508            installer_category: Some("portable".to_owned()),
15509            install_location: None,
15510            package_family_names: Vec::new(),
15511            product_codes: Vec::new(),
15512            upgrade_codes: Vec::new(),
15513            correlated: None,
15514            winget_package_identifier: None,
15515            winget_source_identifier: None,
15516            installed_version_canonical: false,
15517            correlated_requires_explicit_upgrade: false,
15518            correlated_lacks_compatible_installer: false,
15519        };
15520        let sources = vec![
15521            SourceRecord {
15522                name: "long".to_owned(),
15523                kind: SourceKind::PreIndexed,
15524                arg: "https://example.invalid/long".to_owned(),
15525                identifier: "identifier-longer-than-local-id".to_owned(),
15526                trust_level: "Trusted".to_owned(),
15527                explicit: false,
15528                priority: 0,
15529                last_update: None,
15530                source_version: None,
15531                etag: None,
15532                last_modified: None,
15533            },
15534            SourceRecord {
15535                name: "short".to_owned(),
15536                kind: SourceKind::PreIndexed,
15537                arg: "https://example.invalid/short".to_owned(),
15538                identifier: "short".to_owned(),
15539                trust_level: "Trusted".to_owned(),
15540                explicit: false,
15541                priority: 1,
15542                last_update: None,
15543                source_version: None,
15544                etag: None,
15545                last_modified: None,
15546            },
15547        ];
15548
15549        let item = list_match_from_installed(package, &sources);
15550
15551        assert_eq!(item.id, "Vendor.Example");
15552        assert_eq!(item.source_name.as_deref(), Some("short"));
15553    }
15554
15555    #[test]
15556    fn list_response_serialization_keeps_pascal_case_fields() {
15557        let response = ListResponse {
15558            matches: vec![ListMatch {
15559                name: "PowerToys".to_owned(),
15560                id: "Microsoft.PowerToys".to_owned(),
15561                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
15562                installed_version: "0.98.1".to_owned(),
15563                available_version: Some("0.99.0".to_owned()),
15564                source_name: Some("winget".to_owned()),
15565                publisher: Some("Microsoft".to_owned()),
15566                scope: Some("Machine".to_owned()),
15567                installer_category: Some("msi".to_owned()),
15568                install_location: None,
15569                package_family_names: Vec::new(),
15570                product_codes: Vec::new(),
15571                upgrade_codes: Vec::new(),
15572            }],
15573            warnings: Vec::new(),
15574            truncated: false,
15575        };
15576
15577        let value = serde_json::to_value(&response).expect("serialize list response");
15578        let match_value = &value["Matches"][0];
15579
15580        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
15581        assert_eq!(match_value["InstalledVersion"], "0.98.1");
15582        assert_eq!(match_value["AvailableVersion"], "0.99.0");
15583        assert_eq!(match_value["SourceName"], "winget");
15584        assert!(match_value.get("local_id").is_none());
15585        assert!(match_value.get("installed_version").is_none());
15586        assert!(match_value.get("available_version").is_none());
15587        assert!(match_value.get("source_name").is_none());
15588    }
15589
15590    #[test]
15591    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
15592        let response = SearchResponse {
15593            matches: vec![SearchMatch {
15594                source_name: "msstore".to_owned(),
15595                source_kind: SourceKind::Rest,
15596                id: "9WZDNCRFJBMP".to_owned(),
15597                name: "Microsoft To Do".to_owned(),
15598                moniker: None,
15599                version: Some("2.123.456.0".to_owned()),
15600                channel: None,
15601                match_criteria: None,
15602            }],
15603            warnings: Vec::new(),
15604            truncated: false,
15605        };
15606
15607        let value = serde_json::to_value(&response).expect("serialize search response");
15608        let match_value = &value["Matches"][0];
15609
15610        assert_eq!(match_value["Name"], "Microsoft To Do");
15611        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
15612        assert_eq!(match_value["Version"], "2.123.456.0");
15613        assert_eq!(match_value["SourceName"], "msstore");
15614        assert!(match_value.get("source_name").is_none());
15615    }
15616
15617    #[test]
15618    fn versions_result_serialization_keeps_pascal_case_fields() {
15619        let result = VersionsResult {
15620            package: SearchMatch {
15621                source_name: "winget".to_owned(),
15622                source_kind: SourceKind::PreIndexed,
15623                id: "Microsoft.PowerToys".to_owned(),
15624                name: "PowerToys".to_owned(),
15625                moniker: None,
15626                version: Some("0.99.0".to_owned()),
15627                channel: None,
15628                match_criteria: None,
15629            },
15630            versions: vec![
15631                VersionKey {
15632                    version: "0.99.0".to_owned(),
15633                    channel: "stable".to_owned(),
15634                },
15635                VersionKey {
15636                    version: "0.100.0-preview".to_owned(),
15637                    channel: "preview".to_owned(),
15638                },
15639            ],
15640            warnings: Vec::new(),
15641        };
15642
15643        let value = serde_json::to_value(&result).expect("serialize versions result");
15644
15645        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
15646        assert_eq!(value["Versions"][0]["Channel"], "stable");
15647        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
15648        assert_eq!(value["Versions"][1]["Channel"], "preview");
15649        assert!(value.get("versions").is_none());
15650    }
15651
15652    #[test]
15653    fn version_data_parses_arp_bounds_from_winget_payload() {
15654        // Sanity check that aMiV/aMaV deserialize from a real-world
15655        // versionData.mszyml payload shape. Without `default`, packages
15656        // predating AppsAndFeaturesEntries would fail to parse.
15657        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";
15658
15659        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
15660        assert_eq!(document.versions.len(), 2);
15661        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
15662        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
15663        assert!(document.versions[1].arp_min_version.is_none());
15664        assert!(document.versions[1].arp_max_version.is_none());
15665    }
15666
15667    #[test]
15668    fn msix_resource_string_name_resolves_to_catalog_name() {
15669        // App Installer's MSIX manifest stores DisplayName as
15670        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
15671        // know the catalog calls it `App Installer` — show that instead of
15672        // the unresolved placeholder, matching winget's output.
15673        let mut package = InstalledPackage {
15674            name: "ms-resource:appDisplayName".to_owned(),
15675            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
15676            installed_version: "1.28.239.0".to_owned(),
15677            publisher: None,
15678            scope: Some("User".to_owned()),
15679            installer_category: Some("msix".to_owned()),
15680            install_location: None,
15681            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
15682            product_codes: Vec::new(),
15683            upgrade_codes: Vec::new(),
15684            correlated: Some(SearchMatch {
15685                source_name: "winget".to_owned(),
15686                source_kind: SourceKind::PreIndexed,
15687                id: "Microsoft.AppInstaller".to_owned(),
15688                name: "App Installer".to_owned(),
15689                moniker: None,
15690                version: Some("1.28.240.0".to_owned()),
15691                channel: None,
15692                match_criteria: Some("PackageFamilyName".to_owned()),
15693            }),
15694            winget_package_identifier: None,
15695            winget_source_identifier: None,
15696            installed_version_canonical: false,
15697            correlated_requires_explicit_upgrade: false,
15698            correlated_lacks_compatible_installer: false,
15699        };
15700        apply_msix_resource_string_name_fix(&mut package);
15701        assert_eq!(package.name, "App Installer");
15702    }
15703
15704    #[test]
15705    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
15706        // ARP rows can also carry unusual display names; the fix is gated
15707        // on the local_id prefix so a `ms-resource:` literal that somehow
15708        // appears in an ARP DisplayName (extremely unlikely, but possible)
15709        // doesn't get silently rewritten.
15710        let mut package = InstalledPackage {
15711            name: "ms-resource:appDisplayName".to_owned(),
15712            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
15713            installed_version: "1.0".to_owned(),
15714            publisher: None,
15715            scope: Some("Machine".to_owned()),
15716            installer_category: Some("msi".to_owned()),
15717            install_location: None,
15718            package_family_names: Vec::new(),
15719            product_codes: Vec::new(),
15720            upgrade_codes: Vec::new(),
15721            correlated: Some(SearchMatch {
15722                source_name: "winget".to_owned(),
15723                source_kind: SourceKind::PreIndexed,
15724                id: "Some.Package".to_owned(),
15725                name: "Should Not Apply".to_owned(),
15726                moniker: None,
15727                version: Some("1.0".to_owned()),
15728                channel: None,
15729                match_criteria: None,
15730            }),
15731            winget_package_identifier: None,
15732            winget_source_identifier: None,
15733            installed_version_canonical: false,
15734            correlated_requires_explicit_upgrade: false,
15735            correlated_lacks_compatible_installer: false,
15736        };
15737        apply_msix_resource_string_name_fix(&mut package);
15738        assert_eq!(package.name, "ms-resource:appDisplayName");
15739    }
15740
15741    #[test]
15742    fn msix_resource_string_fix_skips_resolved_names() {
15743        // The vast majority of MSIX entries have already-resolved names
15744        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
15745        // those — `meta.name` and the installed name may legitimately
15746        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
15747        let mut package = InstalledPackage {
15748            name: "Microsoft Teams".to_owned(),
15749            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
15750            installed_version: "25290.205.4069.4894".to_owned(),
15751            publisher: None,
15752            scope: Some("User".to_owned()),
15753            installer_category: Some("msix".to_owned()),
15754            install_location: None,
15755            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
15756            product_codes: Vec::new(),
15757            upgrade_codes: Vec::new(),
15758            correlated: Some(SearchMatch {
15759                source_name: "winget".to_owned(),
15760                source_kind: SourceKind::PreIndexed,
15761                id: "Microsoft.Teams".to_owned(),
15762                name: "Microsoft Teams Catalog Name".to_owned(),
15763                moniker: None,
15764                version: Some("26106.1906.4665.7308".to_owned()),
15765                channel: None,
15766                match_criteria: Some("PackageFamilyName".to_owned()),
15767            }),
15768            winget_package_identifier: None,
15769            winget_source_identifier: None,
15770            installed_version_canonical: false,
15771            correlated_requires_explicit_upgrade: false,
15772            correlated_lacks_compatible_installer: false,
15773        };
15774        apply_msix_resource_string_name_fix(&mut package);
15775        assert_eq!(package.name, "Microsoft Teams");
15776    }
15777
15778    #[cfg(windows)]
15779    #[test]
15780    fn unflip_packed_guid_reverses_msi_installer_packing() {
15781        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
15782        // chunks (8/4/4/2*8). This is the encoding used inside
15783        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
15784        // and their value names. Verified against the user's installed
15785        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
15786        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
15787        assert_eq!(
15788            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
15789            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
15790        );
15791        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
15792        // form `A3A70C74FE2431248AD5F8A59570C782`.
15793        assert_eq!(
15794            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
15795            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
15796        );
15797        // Reject non-32-char or non-hex inputs.
15798        assert!(unflip_packed_guid("nothex").is_none());
15799        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
15800        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
15801    }
15802
15803    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
15804        InstalledPackage {
15805            name: format!("{id} install"),
15806            local_id: format!(r"ARP\Machine\X64\{id}"),
15807            installed_version: installed_version.to_owned(),
15808            publisher: None,
15809            scope: Some("Machine".to_owned()),
15810            installer_category: Some("msi".to_owned()),
15811            install_location: None,
15812            package_family_names: Vec::new(),
15813            product_codes: Vec::new(),
15814            upgrade_codes: Vec::new(),
15815            correlated: Some(SearchMatch {
15816                source_name: "winget".to_owned(),
15817                source_kind: SourceKind::PreIndexed,
15818                id: id.to_owned(),
15819                name: id.to_owned(),
15820                moniker: None,
15821                version: Some("99.0.0".to_owned()),
15822                channel: None,
15823                match_criteria: None,
15824            }),
15825            winget_package_identifier: None,
15826            winget_source_identifier: None,
15827            installed_version_canonical: canonical,
15828            correlated_requires_explicit_upgrade: false,
15829            correlated_lacks_compatible_installer: false,
15830        }
15831    }
15832
15833    #[test]
15834    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
15835        // Two installed entries correlate to the same catalog id. One had its
15836        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
15837        // the other did not (e.g. VS-installed .NET SDK whose ARP
15838        // `40.10.18029` doesn't fit any bucket). Without the canonical
15839        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
15840        // wrong row wins and the upgrade silently disappears.
15841        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
15842        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
15843
15844        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
15845        assert_eq!(result.len(), 1);
15846        assert_eq!(result[0].installed_version, "10.0.108");
15847        assert!(result[0].installed_version_canonical);
15848    }
15849
15850    #[test]
15851    fn dedupe_keeps_highest_among_canonical_rows() {
15852        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
15853        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
15854
15855        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
15856        assert_eq!(result.len(), 1);
15857        assert_eq!(result[0].installed_version, "1.7.9");
15858    }
15859
15860    #[test]
15861    fn dedupe_leaves_uncorrelated_rows_alone() {
15862        // Uncorrelated entries don't reach upgrade output anyway (the
15863        // upgrade filter requires correlation), but the dedupe pass must not
15864        // drop them — `pinget list` could share this code path in the future.
15865        let uncorrelated = InstalledPackage {
15866            name: "Foo".to_owned(),
15867            local_id: r"ARP\Machine\X64\Foo".to_owned(),
15868            installed_version: "1.0".to_owned(),
15869            publisher: None,
15870            scope: Some("Machine".to_owned()),
15871            installer_category: Some("exe".to_owned()),
15872            install_location: None,
15873            package_family_names: Vec::new(),
15874            product_codes: Vec::new(),
15875            upgrade_codes: Vec::new(),
15876            correlated: None,
15877            winget_package_identifier: None,
15878            winget_source_identifier: None,
15879            installed_version_canonical: false,
15880            correlated_requires_explicit_upgrade: false,
15881            correlated_lacks_compatible_installer: false,
15882        };
15883        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
15884        assert_eq!(result.len(), 1);
15885    }
15886}