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::ffi::OsString;
6use std::fmt::{Display, Formatter};
7use std::fs::{self, OpenOptions};
8use std::io::{Cursor, Read, Write};
9#[cfg(windows)]
10use std::mem::{MaybeUninit, size_of};
11#[cfg(windows)]
12use std::os::windows::ffi::{OsStrExt, OsStringExt};
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex, OnceLock, RwLock};
15use std::time::{Duration as StdDuration, Instant};
16
17use anyhow::{Context, Result, anyhow, bail};
18use chrono::{DateTime, Duration, Utc};
19use reqwest::StatusCode;
20use reqwest::blocking::{Client, Response};
21use reqwest::header::{
22    ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE,
23    IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, RANGE,
24};
25use serde::{Deserialize, Serialize};
26use serde_json::{Map as JsonMap, Value as JsonValue};
27use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
28use sha2::{Digest, Sha256};
29use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
30#[cfg(windows)]
31use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
32#[cfg(windows)]
33use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
34#[cfg(windows)]
35use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
36#[cfg(windows)]
37use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
38#[cfg(windows)]
39use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
40#[cfg(windows)]
41use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
42#[cfg(windows)]
43use winreg::{RegKey, enums::*};
44use zip::ZipArchive;
45
46const DEFAULT_MARKET: &str = "US";
47const DEFAULT_MAX_RESULTS: usize = 50;
48const LIST_LOOKUP_MAX_RESULTS: usize = 500;
49const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
50const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
51const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15;
52const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
53const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json";
54const SYSTEM_WINGET_MIRROR_EXPORT_STAMP_FILE_NAME: &str = "system-sources.stamp";
55const APP_ROOT_ENV_VAR: &str = "PINGET_APPROOT";
56const SOURCE_MODE_ENV_VAR: &str = "PINGET_SOURCE_MODE";
57const SYSTEM_WINGET_PROGRAM_ENV_VAR: &str = "PINGET_WINGET_PATH";
58const SYSTEM_WINGET_MIRROR_AUTO_REFRESH_MINUTES: u64 = 15;
59#[cfg(windows)]
60const SYSTEM_WINGET_PROGRAM_NAME: &str = "winget.exe";
61#[cfg(not(windows))]
62const SYSTEM_WINGET_PROGRAM_NAME: &str = "winget";
63#[cfg(windows)]
64const APPMODEL_PACKAGES_PATH: &str =
65    r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
66#[cfg(windows)]
67const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
68#[cfg(windows)]
69const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
70const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
71    "Installed package discovery is not supported on this platform; returning no installed packages.";
72const INSTALL_UNSUPPORTED_WARNING: &str =
73    "Installing packages is not supported on this platform; no changes were made.";
74const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
75const REPAIR_REINSTALL_WARNING: &str =
76    "Pinget repair currently re-runs the package install flow for the selected package.";
77const UNINSTALL_UNSUPPORTED_WARNING: &str =
78    "Uninstalling packages is not supported on this platform; no changes were made.";
79#[cfg(windows)]
80const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
81const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
82    "LocalManifestFiles",
83    "BypassCertificatePinningForMicrosoftStore",
84    "InstallerHashOverride",
85    "LocalArchiveMalwareScanOverride",
86    "ProxyCommandLineOptions",
87];
88const REST_SUPPORTED_CONTRACTS: &[&str] = &[
89    "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",
90];
91
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
93pub enum SourceKind {
94    #[serde(rename = "PreIndexed", alias = "preIndexed")]
95    PreIndexed,
96    #[serde(rename = "Rest", alias = "rest")]
97    Rest,
98}
99
100impl Display for SourceKind {
101    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
102        match self {
103            SourceKind::PreIndexed => f.write_str("preindexed"),
104            SourceKind::Rest => f.write_str("rest"),
105        }
106    }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct SourceRecord {
111    pub name: String,
112    pub kind: SourceKind,
113    pub arg: String,
114    pub identifier: String,
115    #[serde(default = "default_source_trust_level")]
116    pub trust_level: String,
117    #[serde(default)]
118    pub explicit: bool,
119    #[serde(default)]
120    pub priority: i32,
121    #[serde(default)]
122    pub last_update: Option<DateTime<Utc>>,
123    #[serde(default)]
124    pub source_version: Option<String>,
125    #[serde(default)]
126    pub etag: Option<String>,
127    #[serde(default)]
128    pub last_modified: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132struct SourceStore {
133    sources: Vec<SourceRecord>,
134}
135
136impl Default for SourceStore {
137    fn default() -> Self {
138        Self {
139            sources: vec![
140                SourceRecord {
141                    name: "winget".to_owned(),
142                    kind: SourceKind::PreIndexed,
143                    arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
144                    identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".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                SourceRecord {
154                    name: "msstore".to_owned(),
155                    kind: SourceKind::Rest,
156                    arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
157                    identifier: "StoreEdgeFD".to_owned(),
158                    trust_level: "Trusted".to_owned(),
159                    explicit: false,
160                    priority: 0,
161                    last_update: None,
162                    source_version: None,
163                    etag: None,
164                    last_modified: None,
165                },
166            ],
167        }
168    }
169}
170
171/// Options for embedding the WinGet core library in another application.
172#[derive(Debug, Clone)]
173pub struct RepositoryOptions {
174    pub app_root: PathBuf,
175    pub user_agent: String,
176    pub source_mode: SourceMode,
177    pub pre_indexed_source_auto_update_interval: Option<Duration>,
178    pub install_progress: Option<fn(&InstallProgress)>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum SourceMode {
183    Auto,
184    Private,
185    SystemWingetMirror,
186}
187
188impl SourceMode {
189    fn parse(value: &str) -> Option<Self> {
190        match value.trim().to_ascii_lowercase().as_str() {
191            "auto" => Some(Self::Auto),
192            "private" => Some(Self::Private),
193            "system-winget-mirror" | "systemwingetmirror" => Some(Self::SystemWingetMirror),
194            _ => None,
195        }
196    }
197
198    fn from_env() -> Option<Self> {
199        Self::parse(&std::env::var(SOURCE_MODE_ENV_VAR).ok()?)
200    }
201
202    fn resolve(from_env: Option<Self>, has_app_root_override: bool) -> Self {
203        from_env.unwrap_or(if has_app_root_override {
204            Self::Private
205        } else {
206            Self::Auto
207        })
208    }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212enum EffectiveSourceMode {
213    Private,
214    SystemWingetDirect,
215    SystemWingetMirror,
216}
217
218impl RepositoryOptions {
219    /// Creates library options that keep all persistent state under the supplied root.
220    pub fn new(app_root: impl Into<PathBuf>) -> Self {
221        Self {
222            app_root: app_root.into(),
223            user_agent: DEFAULT_USER_AGENT.to_owned(),
224            source_mode: SourceMode::Private,
225            pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
226            install_progress: None,
227        }
228    }
229
230    /// Uses the default per-user app-data root that the CLI also uses.
231    pub fn for_current_user() -> Result<Self> {
232        let source_mode = SourceMode::resolve(SourceMode::from_env(), std::env::var_os(APP_ROOT_ENV_VAR).is_some());
233        Ok(Self::new(default_app_root()?).with_source_mode(source_mode))
234    }
235
236    /// Overrides the HTTP user-agent sent to source endpoints.
237    #[must_use]
238    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
239        self.user_agent = user_agent.into();
240        self
241    }
242
243    #[must_use]
244    pub fn with_source_mode(mut self, source_mode: SourceMode) -> Self {
245        self.source_mode = source_mode;
246        self
247    }
248
249    /// Overrides automatic freshness checks for existing pre-indexed source indexes.
250    #[must_use]
251    pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
252        self.pre_indexed_source_auto_update_interval = interval;
253        self
254    }
255
256    /// Receives coarse install/update phase notifications. Intended for CLIs
257    /// and host applications that need to show progress while downloads or
258    /// silent installers are running.
259    #[must_use]
260    pub fn with_install_progress(mut self, progress: fn(&InstallProgress)) -> Self {
261        self.install_progress = Some(progress);
262        self
263    }
264}
265
266#[derive(Debug, Clone, Default)]
267pub struct PackageQuery {
268    pub query: Option<String>,
269    pub id: Option<String>,
270    pub name: Option<String>,
271    pub moniker: Option<String>,
272    pub tag: Option<String>,
273    pub command: Option<String>,
274    pub source: Option<String>,
275    pub count: Option<usize>,
276    pub exact: bool,
277    pub version: Option<String>,
278    pub channel: Option<String>,
279    pub locale: Option<String>,
280    pub installer_type: Option<String>,
281    pub installer_architecture: Option<String>,
282    pub platform: Option<String>,
283    pub os_version: Option<String>,
284    pub install_scope: Option<String>,
285}
286
287#[derive(Debug, Clone, Default)]
288pub struct ListQuery {
289    pub query: Option<String>,
290    pub id: Option<String>,
291    pub name: Option<String>,
292    pub moniker: Option<String>,
293    pub tag: Option<String>,
294    pub command: Option<String>,
295    pub product_code: Option<String>,
296    pub version: Option<String>,
297    pub source: Option<String>,
298    pub count: Option<usize>,
299    pub exact: bool,
300    pub install_scope: Option<String>,
301    pub upgrade_only: bool,
302    pub include_unknown: bool,
303    pub include_pinned: bool,
304}
305
306#[derive(Debug, Clone, serde::Serialize)]
307#[serde(rename_all = "PascalCase")]
308pub struct SearchMatch {
309    pub source_name: String,
310    pub source_kind: SourceKind,
311    pub id: String,
312    pub name: String,
313    pub moniker: Option<String>,
314    pub version: Option<String>,
315    pub channel: Option<String>,
316    pub match_criteria: Option<String>,
317}
318
319#[derive(Debug, Clone, serde::Serialize)]
320#[serde(rename_all = "PascalCase")]
321pub struct SearchResponse {
322    pub matches: Vec<SearchMatch>,
323    pub warnings: Vec<String>,
324    pub truncated: bool,
325}
326
327#[derive(Debug, Clone, serde::Serialize)]
328#[serde(rename_all = "PascalCase")]
329pub struct ListMatch {
330    pub name: String,
331    pub id: String,
332    pub local_id: String,
333    pub installed_version: String,
334    pub available_version: Option<String>,
335    pub source_name: Option<String>,
336    pub publisher: Option<String>,
337    pub scope: Option<String>,
338    pub installer_category: Option<String>,
339    pub install_location: Option<String>,
340    pub package_family_names: Vec<String>,
341    pub product_codes: Vec<String>,
342    pub upgrade_codes: Vec<String>,
343}
344
345#[derive(Debug, Clone, serde::Serialize)]
346#[serde(rename_all = "PascalCase")]
347pub struct ListResponse {
348    pub matches: Vec<ListMatch>,
349    pub warnings: Vec<String>,
350    pub truncated: bool,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
354#[serde(rename_all = "PascalCase")]
355pub struct VersionKey {
356    pub version: String,
357    pub channel: String,
358}
359
360#[derive(Debug, Clone, serde::Serialize)]
361pub struct Manifest {
362    pub id: String,
363    pub name: String,
364    pub version: String,
365    pub channel: String,
366    pub publisher: Option<String>,
367    pub description: Option<String>,
368    pub moniker: Option<String>,
369    pub package_url: Option<String>,
370    pub publisher_url: Option<String>,
371    pub publisher_support_url: Option<String>,
372    pub license: Option<String>,
373    pub license_url: Option<String>,
374    pub privacy_url: Option<String>,
375    pub author: Option<String>,
376    pub copyright: Option<String>,
377    pub copyright_url: Option<String>,
378    pub release_notes: Option<String>,
379    pub release_notes_url: Option<String>,
380    pub tags: Vec<String>,
381    pub agreements: Vec<PackageAgreement>,
382    pub package_dependencies: Vec<String>,
383    pub documentation: Vec<Documentation>,
384    pub icons: Vec<PackageIcon>,
385    pub installers: Vec<Installer>,
386    // `RequireExplicitUpgrade: true` opts a package out of bulk
387    // `pinget upgrade` output (winget parity). Users can still upgrade by
388    // explicit `id`. Set at top-level or per-installer; treated as true
389    // when any installer asserts it.
390    pub require_explicit_upgrade: bool,
391}
392
393impl Manifest {
394    pub fn icon(&self) -> Option<&PackageIcon> {
395        select_best_icon(&self.icons)
396    }
397
398    pub fn icon_url(&self) -> Option<&str> {
399        self.icon().and_then(|icon| icon.icon_url.as_deref())
400    }
401}
402
403#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
404#[serde(rename_all = "PascalCase")]
405pub struct PackageIcon {
406    pub icon_url: Option<String>,
407    pub icon_file_type: Option<String>,
408    pub icon_resolution: Option<String>,
409    pub icon_theme: Option<String>,
410    pub icon_sha256: Option<String>,
411}
412
413impl PackageIcon {
414    fn is_empty(&self) -> bool {
415        self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty())
416            && self
417                .icon_file_type
418                .as_deref()
419                .is_none_or(|value| value.trim().is_empty())
420            && self
421                .icon_resolution
422                .as_deref()
423                .is_none_or(|value| value.trim().is_empty())
424            && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty())
425            && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty())
426    }
427}
428
429fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> {
430    icons
431        .iter()
432        .enumerate()
433        .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty()))
434        .max_by(|(left_index, left), (right_index, right)| {
435            icon_theme_score(left.icon_theme.as_deref())
436                .cmp(&icon_theme_score(right.icon_theme.as_deref()))
437                .then_with(|| {
438                    icon_resolution_score(left.icon_resolution.as_deref())
439                        .cmp(&icon_resolution_score(right.icon_resolution.as_deref()))
440                })
441                .then_with(|| {
442                    icon_file_type_score(left.icon_file_type.as_deref())
443                        .cmp(&icon_file_type_score(right.icon_file_type.as_deref()))
444                })
445                .then_with(|| right_index.cmp(left_index))
446        })
447        .map(|(_, icon)| icon)
448}
449
450fn icon_theme_score(theme: Option<&str>) -> usize {
451    match theme {
452        None => 1,
453        Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1,
454        Some(_) => 0,
455    }
456}
457
458fn icon_file_type_score(file_type: Option<&str>) -> usize {
459    match file_type {
460        Some(value) if value.eq_ignore_ascii_case("ico") => 2,
461        Some(value) if value.eq_ignore_ascii_case("png") => 1,
462        _ => 0,
463    }
464}
465
466fn icon_resolution_score(resolution: Option<&str>) -> usize {
467    let Some(resolution) = resolution else {
468        return 0;
469    };
470    let resolution = resolution.trim();
471    let Some(separator) = resolution.find(['x', 'X']) else {
472        return 0;
473    };
474    let (width, height) = resolution.split_at(separator);
475    let height = &height[1..];
476    let (Ok(width), Ok(height)) = (width.parse::<usize>(), height.parse::<usize>()) else {
477        return 0;
478    };
479    if width == height { width } else { 0 }
480}
481
482#[derive(Debug, Clone, serde::Serialize)]
483pub struct Documentation {
484    pub label: Option<String>,
485    pub url: String,
486}
487
488#[derive(Debug, Clone, serde::Serialize)]
489pub struct PackageAgreement {
490    pub label: Option<String>,
491    pub text: Option<String>,
492    pub url: Option<String>,
493}
494
495#[derive(Debug, Clone, Default, serde::Serialize)]
496pub struct Installer {
497    pub architecture: Option<String>,
498    pub installer_type: Option<String>,
499    pub nested_installer_type: Option<String>,
500    pub nested_installer_files: Vec<NestedInstallerFile>,
501    pub url: Option<String>,
502    pub sha256: Option<String>,
503    pub product_code: Option<String>,
504    pub locale: Option<String>,
505    pub scope: Option<String>,
506    pub release_date: Option<String>,
507    pub package_family_name: Option<String>,
508    pub upgrade_code: Option<String>,
509    pub platforms: Vec<String>,
510    pub minimum_os_version: Option<String>,
511    pub switches: InstallerSwitches,
512    pub commands: Vec<String>,
513    pub package_dependencies: Vec<String>,
514    pub require_explicit_upgrade: bool,
515}
516
517#[derive(Debug, Clone, Default, serde::Serialize)]
518pub struct NestedInstallerFile {
519    /// Path to the executable within the extracted archive.
520    pub relative_file_path: String,
521    /// Optional alias winget exposes for portable invocation. Pinget records it
522    /// for parity and uses it during portable installation to create a
523    /// `Links\\<alias>.exe` symlink; `Links\\` may also be added to the user PATH.
524    pub portable_command_alias: Option<String>,
525}
526
527#[derive(Debug, Clone, Default, serde::Serialize)]
528pub struct InstallerSwitches {
529    pub silent: Option<String>,
530    pub silent_with_progress: Option<String>,
531    pub interactive: Option<String>,
532    pub custom: Option<String>,
533    pub log: Option<String>,
534    pub install_location: Option<String>,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538pub enum InstallerMode {
539    Interactive,
540    SilentWithProgress,
541    Silent,
542}
543
544#[derive(Debug, Clone)]
545pub struct InstallRequest {
546    pub query: PackageQuery,
547    pub manifest_path: Option<PathBuf>,
548    pub mode: InstallerMode,
549    pub log_path: Option<PathBuf>,
550    pub custom: Option<String>,
551    pub override_args: Option<String>,
552    pub install_location: Option<String>,
553    pub skip_dependencies: bool,
554    pub dependencies_only: bool,
555    pub accept_package_agreements: bool,
556    pub force: bool,
557    pub rename: Option<String>,
558    pub uninstall_previous: bool,
559    pub ignore_security_hash: bool,
560    pub dependency_source: Option<String>,
561    pub no_upgrade: bool,
562}
563
564impl InstallRequest {
565    pub fn new(query: PackageQuery) -> Self {
566        Self {
567            query,
568            manifest_path: None,
569            mode: InstallerMode::SilentWithProgress,
570            log_path: None,
571            custom: None,
572            override_args: None,
573            install_location: None,
574            skip_dependencies: false,
575            dependencies_only: false,
576            accept_package_agreements: false,
577            force: false,
578            rename: None,
579            uninstall_previous: false,
580            ignore_security_hash: false,
581            dependency_source: None,
582            no_upgrade: false,
583        }
584    }
585}
586
587#[derive(Debug, Clone)]
588pub struct UninstallRequest {
589    pub query: PackageQuery,
590    pub manifest_path: Option<PathBuf>,
591    pub product_code: Option<String>,
592    pub mode: InstallerMode,
593    pub all_versions: bool,
594    pub force: bool,
595    pub purge: bool,
596    pub preserve: bool,
597    pub log_path: Option<PathBuf>,
598}
599
600impl UninstallRequest {
601    pub fn new(query: PackageQuery) -> Self {
602        Self {
603            query,
604            manifest_path: None,
605            product_code: None,
606            mode: InstallerMode::SilentWithProgress,
607            all_versions: false,
608            force: false,
609            purge: false,
610            preserve: false,
611            log_path: None,
612        }
613    }
614}
615
616#[derive(Debug, Clone)]
617pub struct RepairRequest {
618    pub query: PackageQuery,
619    pub manifest_path: Option<PathBuf>,
620    pub product_code: Option<String>,
621    pub mode: InstallerMode,
622    pub log_path: Option<PathBuf>,
623    pub accept_package_agreements: bool,
624    pub force: bool,
625    pub ignore_security_hash: bool,
626}
627
628impl RepairRequest {
629    pub fn new(query: PackageQuery) -> Self {
630        Self {
631            query,
632            manifest_path: None,
633            product_code: None,
634            mode: InstallerMode::SilentWithProgress,
635            log_path: None,
636            accept_package_agreements: false,
637            force: false,
638            ignore_security_hash: false,
639        }
640    }
641}
642
643#[derive(Debug, Clone, serde::Serialize)]
644pub struct ShowResult {
645    pub package: SearchMatch,
646    pub manifest: Manifest,
647    pub selected_installer: Option<Installer>,
648    pub cached_files: Vec<PathBuf>,
649    pub warnings: Vec<String>,
650    #[serde(skip_serializing)]
651    manifest_documents: JsonValue,
652}
653
654impl ShowResult {
655    pub fn structured_document(&self) -> JsonValue {
656        add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest)
657    }
658}
659
660/// Result of a `download` operation. In addition to the resolved installer
661/// file on disk, pinget mirrors `winget download` by writing a flattened YAML
662/// manifest next to the installer so users get a self-contained snapshot of
663/// the package version they downloaded.
664#[derive(Debug, Clone, serde::Serialize)]
665pub struct DownloadOutput {
666    pub manifest: Manifest,
667    pub installer_path: PathBuf,
668    pub manifest_path: PathBuf,
669}
670
671/// Internal result of fetching only the installer file. Used by the install
672/// flow which downloads into a temp dir and intentionally skips the manifest
673/// YAML side effect.
674struct FetchedInstaller {
675    manifest: Manifest,
676    manifest_documents: JsonValue,
677    installer: Installer,
678    installer_path: PathBuf,
679}
680
681#[derive(Debug, Clone, serde::Serialize)]
682#[serde(rename_all = "PascalCase")]
683pub struct VersionsResult {
684    pub package: SearchMatch,
685    pub versions: Vec<VersionKey>,
686    pub warnings: Vec<String>,
687}
688
689#[derive(Debug, Clone, serde::Serialize)]
690#[serde(rename_all = "PascalCase")]
691pub struct CacheWarmResult {
692    pub package: SearchMatch,
693    pub cached_files: Vec<PathBuf>,
694    pub warnings: Vec<String>,
695}
696
697#[derive(Debug, Clone, serde::Serialize)]
698#[serde(rename_all = "PascalCase")]
699pub struct SourceUpdateResult {
700    pub name: String,
701    pub kind: SourceKind,
702    pub detail: String,
703}
704
705#[derive(Debug, Clone, serde::Serialize)]
706#[serde(rename_all = "PascalCase")]
707pub struct PinRecord {
708    pub package_id: String,
709    pub version: String,
710    pub source_id: String,
711    pub pin_type: PinType,
712}
713
714#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
715pub enum PinType {
716    Pinning,
717    Blocking,
718    Gating,
719}
720
721impl Display for PinType {
722    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
723        match self {
724            PinType::Pinning => f.write_str("Pinning"),
725            PinType::Blocking => f.write_str("Blocking"),
726            PinType::Gating => f.write_str("Gating"),
727        }
728    }
729}
730
731/// Result of an install/uninstall/upgrade operation
732#[derive(Debug, Clone, serde::Serialize)]
733pub struct InstallResult {
734    pub package_id: String,
735    pub version: String,
736    pub installer_path: PathBuf,
737    pub installer_type: String,
738    pub exit_code: i32,
739    pub success: bool,
740    pub no_op: bool,
741    pub warnings: Vec<String>,
742}
743
744#[derive(Debug, Clone)]
745pub enum InstallProgress {
746    ResolvingManifest,
747    ResolvedManifest { package_id: String, version: String },
748    CheckingInstalled,
749    InstallingDependencies,
750    SelectingInstaller,
751    DownloadingInstaller { url: String, path: PathBuf },
752    VerifyingInstaller { path: PathBuf },
753    StartingInstaller { installer_type: String, path: PathBuf },
754    WaitingForInstaller { installer_type: String },
755    InstallerFinished { exit_code: i32 },
756}
757
758impl InstallerSwitches {
759    fn with_fallback(&self, fallback: &Self) -> Self {
760        Self {
761            silent: self.silent.clone().or_else(|| fallback.silent.clone()),
762            silent_with_progress: self
763                .silent_with_progress
764                .clone()
765                .or_else(|| fallback.silent_with_progress.clone()),
766            interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
767            custom: self.custom.clone().or_else(|| fallback.custom.clone()),
768            log: self.log.clone().or_else(|| fallback.log.clone()),
769            install_location: self
770                .install_location
771                .clone()
772                .or_else(|| fallback.install_location.clone()),
773        }
774    }
775}
776
777fn installed_package_discovery_supported() -> bool {
778    cfg!(windows)
779}
780
781fn package_actions_supported() -> bool {
782    cfg!(windows)
783}
784
785fn unsupported_action_result(
786    package_id: impl Into<String>,
787    version: impl Into<String>,
788    installer_type: impl Into<String>,
789    warning: &str,
790) -> InstallResult {
791    InstallResult {
792        package_id: package_id.into(),
793        version: version.into(),
794        installer_path: PathBuf::new(),
795        installer_type: installer_type.into(),
796        exit_code: 0,
797        success: true,
798        no_op: true,
799        warnings: vec![warning.to_owned()],
800    }
801}
802
803#[derive(Debug, Clone)]
804struct InstalledPackage {
805    name: String,
806    local_id: String,
807    installed_version: String,
808    publisher: Option<String>,
809    scope: Option<String>,
810    installer_category: Option<String>,
811    install_location: Option<String>,
812    winget_package_identifier: Option<String>,
813    winget_source_identifier: Option<String>,
814    package_family_names: Vec<String>,
815    product_codes: Vec<String>,
816    upgrade_codes: Vec<String>,
817    correlated: Option<SearchMatch>,
818    // True when `installed_version` was rewritten to a catalog Version via
819    // versionData.mszyml's aMiV/aMaV range. Such versions compare meaningfully
820    // against the catalog's Version; raw ARP DisplayVersions usually don't.
821    // Used by dedupe so that, when the same id has both a canonical and a raw
822    // install row, we prefer the canonical (e.g. `Microsoft.DotNet.SDK.10`
823    // 10.0.108 over the VS-installed 40.10.18029 that doesn't map).
824    installed_version_canonical: bool,
825    // True when the correlated catalog package's latest version sets
826    // `RequireExplicitUpgrade: true`. winget hides those rows from bulk
827    // `upgrade`; we mirror that. Users can still upgrade by explicit id.
828    correlated_requires_explicit_upgrade: bool,
829    // True when the correlated catalog package's latest version has no
830    // installer for an architecture the user can actually run. winget
831    // hides those from `upgrade`; we mirror that.
832    correlated_lacks_compatible_installer: bool,
833}
834
835#[derive(Debug, Clone)]
836struct LocatedMatch {
837    display: SearchMatch,
838    source_index: usize,
839    locator: MatchLocator,
840}
841
842#[derive(Debug)]
843struct SearchSourceMatches {
844    matches: Vec<LocatedMatch>,
845    truncated: bool,
846}
847
848#[derive(Debug, Clone)]
849struct SourceSearchFailure {
850    source_name: String,
851    message: String,
852}
853
854#[derive(Debug)]
855struct SearchLocatedResult {
856    matches: Vec<LocatedMatch>,
857    warnings: Vec<String>,
858    failures: Vec<SourceSearchFailure>,
859    truncated: bool,
860}
861
862fn single_match_from_located(
863    located: SearchLocatedResult,
864    report_first_source_failure: bool,
865) -> Result<(LocatedMatch, Vec<String>)> {
866    if located.matches.is_empty() {
867        if report_first_source_failure && let Some(failure) = located.failures.first() {
868            bail!("failed to search source '{}': {}", failure.source_name, failure.message);
869        }
870        bail!("no package matched the supplied query");
871    }
872
873    if located.matches.len() > 1 {
874        let choices = located
875            .matches
876            .iter()
877            .take(10)
878            .map(|item| {
879                format!(
880                    "{} [{}] ({})",
881                    item.display.name, item.display.id, item.display.source_name
882                )
883            })
884            .collect::<Vec<_>>()
885            .join(", ");
886        bail!("multiple packages matched: {choices}");
887    }
888
889    Ok((located.matches.into_iter().next().expect("one match"), located.warnings))
890}
891
892fn should_retry_show_across_system_winget_sources(
893    uses_system_winget_sources: bool,
894    query: &PackageQuery,
895    located: &SearchLocatedResult,
896) -> bool {
897    uses_system_winget_sources
898        && query
899            .source
900            .as_deref()
901            .is_some_and(|source| source.eq_ignore_ascii_case("winget"))
902        && located.matches.is_empty()
903        && located.failures.is_empty()
904}
905
906#[derive(Debug, Clone)]
907enum MatchLocator {
908    PreIndexedV1 {
909        package_rowid: i64,
910    },
911    PreIndexedV2 {
912        package_rowid: i64,
913        package_hash: String,
914    },
915    Rest {
916        package_id: String,
917        versions: Vec<VersionKey>,
918    },
919}
920
921#[derive(Debug, Clone, Copy, PartialEq, Eq)]
922enum SearchSemantics {
923    Many,
924    Single,
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize)]
928struct RestInfoCache {
929    expires_at: DateTime<Utc>,
930    value: RestInformation,
931}
932
933#[derive(Debug, Clone, Default, Serialize, Deserialize)]
934struct RestInformation {
935    #[serde(rename = "SourceIdentifier")]
936    source_identifier: String,
937    #[serde(rename = "ServerSupportedVersions", default)]
938    server_supported_versions: Vec<String>,
939    #[serde(rename = "RequiredPackageMatchFields", default)]
940    required_package_match_fields: Vec<String>,
941    #[serde(rename = "UnsupportedPackageMatchFields", default)]
942    unsupported_package_match_fields: Vec<String>,
943    #[serde(rename = "RequiredQueryParameters", default)]
944    required_query_parameters: Vec<String>,
945    #[serde(rename = "UnsupportedQueryParameters", default)]
946    unsupported_query_parameters: Vec<String>,
947}
948
949#[derive(Debug, Clone, Deserialize)]
950struct PackageVersionDataDocument {
951    #[serde(rename = "vD", default)]
952    versions: Vec<PackageVersionDataEntry>,
953}
954
955#[derive(Debug, Clone, Deserialize)]
956struct PackageVersionDataEntry {
957    #[serde(rename = "v")]
958    version: String,
959    #[serde(rename = "rP")]
960    manifest_relative_path: String,
961    #[serde(rename = "s256H")]
962    manifest_hash: String,
963    // ARP version range covered by this catalog version. WinGet writes these
964    // into versionData.mszyml so an installed ARP DisplayVersion (which may be
965    // an MSI build number with no relation to the catalog Version) can be
966    // mapped back to the catalog Version it corresponds to.
967    #[serde(rename = "aMiV", default)]
968    arp_min_version: Option<String>,
969    #[serde(rename = "aMaV", default)]
970    arp_max_version: Option<String>,
971}
972
973pub struct Repository {
974    app_root: PathBuf,
975    user_agent: String,
976    client: Client,
977    store: SourceStore,
978    source_mode: EffectiveSourceMode,
979    install_progress: Option<fn(&InstallProgress)>,
980    pre_indexed_source_auto_update_interval: Option<Duration>,
981    preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
982}
983
984#[derive(Debug, Clone)]
985struct PreindexedRefreshAttempt {
986    attempted_at: DateTime<Utc>,
987    succeeded: bool,
988}
989
990#[derive(Debug, Clone, Default)]
991struct PreindexedRefreshOutcome {
992    attempted: bool,
993    reused_recent_attempt: bool,
994    error: Option<String>,
995}
996
997struct PreindexedOpenConnection {
998    connection: Connection,
999    refresh: PreindexedRefreshOutcome,
1000}
1001
1002#[derive(Debug)]
1003struct MissingPackageVersionError {
1004    package_id: String,
1005    source_name: String,
1006    requested_version: String,
1007    requested_channel: Option<String>,
1008}
1009
1010impl Display for MissingPackageVersionError {
1011    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1012        let channel = self
1013            .requested_channel
1014            .as_deref()
1015            .filter(|value| !value.is_empty())
1016            .map(|value| format!(" with channel '{value}'"))
1017            .unwrap_or_default();
1018        write!(
1019            f,
1020            "Version '{}'{} was not found for package '{}' in source '{}'.",
1021            self.requested_version, channel, self.package_id, self.source_name
1022        )
1023    }
1024}
1025
1026impl std::error::Error for MissingPackageVersionError {}
1027
1028fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
1029    static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
1030    LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
1031}
1032
1033impl Repository {
1034    /// Opens the repository with the default per-user CLI storage layout.
1035    pub fn open() -> Result<Self> {
1036        Self::open_with_options(RepositoryOptions::for_current_user()?)
1037    }
1038
1039    /// Opens the repository with explicit hosting options for library consumers.
1040    pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
1041        ensure_app_dirs(&options.app_root)?;
1042        let (source_mode, store) = load_effective_source_store(&options.app_root, options.source_mode)?;
1043        let client = Client::builder()
1044            .user_agent(&options.user_agent)
1045            .timeout(StdDuration::from_secs(30 * 60))
1046            .build()
1047            .context("failed to build HTTP client")?;
1048        Ok(Self {
1049            app_root: options.app_root,
1050            user_agent: options.user_agent,
1051            client,
1052            store,
1053            source_mode,
1054            install_progress: options.install_progress,
1055            pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
1056            preindexed_refresh_attempts: HashMap::new(),
1057        })
1058    }
1059
1060    fn uses_system_winget_sources(&self) -> bool {
1061        self.source_mode != EffectiveSourceMode::Private
1062    }
1063
1064    fn mirrors_system_winget_sources(&self) -> bool {
1065        self.source_mode == EffectiveSourceMode::SystemWingetMirror
1066    }
1067
1068    fn emit_install_progress(&self, progress: InstallProgress) {
1069        if let Some(callback) = self.install_progress {
1070            callback(&progress);
1071        }
1072    }
1073
1074    pub fn app_root(&self) -> &Path {
1075        &self.app_root
1076    }
1077
1078    pub fn list_sources(&self) -> Vec<SourceRecord> {
1079        self.store.sources.clone()
1080    }
1081
1082    pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
1083        self.add_source_with_metadata(name, arg, kind, None, false, 0)
1084    }
1085
1086    pub fn add_source_with_metadata(
1087        &mut self,
1088        name: &str,
1089        arg: &str,
1090        kind: SourceKind,
1091        trust_level: Option<&str>,
1092        explicit: bool,
1093        priority: i32,
1094    ) -> Result<()> {
1095        if self.uses_system_winget_sources() {
1096            system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
1097            self.refresh_system_winget_sources()?;
1098            return Ok(());
1099        }
1100
1101        if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
1102            bail!("A source with name '{name}' already exists.");
1103        }
1104        if self.store.sources.iter().any(|s| s.arg == arg) {
1105            bail!("A source with argument '{arg}' already exists.");
1106        }
1107        let identifier = name.to_owned();
1108        self.store.sources.push(SourceRecord {
1109            name: name.to_owned(),
1110            kind,
1111            arg: arg.to_owned(),
1112            identifier,
1113            trust_level: normalize_source_trust_level(trust_level)?,
1114            explicit,
1115            priority,
1116            last_update: None,
1117            source_version: None,
1118            etag: None,
1119            last_modified: None,
1120        });
1121        if self.source_mode != EffectiveSourceMode::SystemWingetDirect {
1122            self.save_store()?;
1123        }
1124        Ok(())
1125    }
1126
1127    pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
1128        if self.uses_system_winget_sources() {
1129            bail!("Editing system WinGet sources is not supported by winget source commands.");
1130        }
1131
1132        let source = self
1133            .store
1134            .sources
1135            .iter_mut()
1136            .find(|source| source.name.eq_ignore_ascii_case(name))
1137            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1138
1139        if let Some(explicit) = explicit {
1140            source.explicit = explicit;
1141        }
1142
1143        if trust_level.is_some() {
1144            source.trust_level = normalize_source_trust_level(trust_level)?;
1145        }
1146
1147        self.save_store()?;
1148        Ok(())
1149    }
1150
1151    pub fn remove_source(&mut self, name: &str) -> Result<()> {
1152        if self.uses_system_winget_sources() {
1153            system_winget_remove_source(name)?;
1154            self.refresh_system_winget_sources()?;
1155            return Ok(());
1156        }
1157
1158        let idx = self
1159            .store
1160            .sources
1161            .iter()
1162            .position(|s| s.name.eq_ignore_ascii_case(name))
1163            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1164        let source = self.store.sources.remove(idx);
1165        // Clean up cached state for this source
1166        let state_dir = self.source_state_dir(&source);
1167        if state_dir.exists() {
1168            let _ = fs::remove_dir_all(&state_dir);
1169        }
1170        self.save_store()?;
1171        Ok(())
1172    }
1173
1174    pub fn reset_source(&mut self, name: &str) -> Result<()> {
1175        if self.uses_system_winget_sources() {
1176            system_winget_reset_source(name)?;
1177            self.refresh_system_winget_sources()?;
1178            return Ok(());
1179        }
1180
1181        let idx = self
1182            .store
1183            .sources
1184            .iter()
1185            .position(|source| source.name.eq_ignore_ascii_case(name))
1186            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1187
1188        let source = self.store.sources[idx].clone();
1189        let state_dir = self.source_state_dir(&source);
1190        if state_dir.exists() {
1191            let _ = fs::remove_dir_all(&state_dir);
1192        }
1193
1194        if let Some(default_source) = SourceStore::default()
1195            .sources
1196            .into_iter()
1197            .find(|source| source.name.eq_ignore_ascii_case(name))
1198        {
1199            self.store.sources[idx] = default_source;
1200        } else {
1201            self.store.sources[idx].last_update = None;
1202            self.store.sources[idx].source_version = None;
1203            self.store.sources[idx].etag = None;
1204            self.store.sources[idx].last_modified = None;
1205        }
1206
1207        self.save_store()?;
1208        Ok(())
1209    }
1210
1211    pub fn reset_sources(&mut self) -> Result<()> {
1212        if self.uses_system_winget_sources() {
1213            system_winget_reset_sources()?;
1214            self.refresh_system_winget_sources()?;
1215            return Ok(());
1216        }
1217
1218        // Remove cached state for all current sources
1219        for source in &self.store.sources {
1220            let state_dir = self.source_state_dir(source);
1221            if state_dir.exists() {
1222                let _ = fs::remove_dir_all(&state_dir);
1223            }
1224        }
1225        self.store = SourceStore::default();
1226        self.save_store()?;
1227        Ok(())
1228    }
1229
1230    pub fn supported_admin_settings() -> &'static [&'static str] {
1231        SUPPORTED_ADMIN_SETTINGS
1232    }
1233
1234    pub fn get_user_settings(&self) -> Result<JsonValue> {
1235        Ok(JsonValue::Object(load_json_object(&user_settings_path(
1236            &self.app_root,
1237        ))?))
1238    }
1239
1240    pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
1241        let update = match user_settings {
1242            JsonValue::Object(object) => object.clone(),
1243            _ => bail!("user settings must be a JSON object"),
1244        };
1245
1246        let effective = if merge {
1247            merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
1248        } else {
1249            update
1250        };
1251        save_json_object(user_settings_path(&self.app_root), &effective)?;
1252        Ok(JsonValue::Object(effective))
1253    }
1254
1255    pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
1256        let current = self.get_user_settings()?;
1257        Ok(if ignore_not_set {
1258            json_contains(&current, expected)
1259        } else {
1260            current == *expected
1261        })
1262    }
1263
1264    pub fn get_admin_settings(&self) -> Result<JsonValue> {
1265        let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
1266        for name in SUPPORTED_ADMIN_SETTINGS {
1267            settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
1268        }
1269
1270        Ok(JsonValue::Object(settings))
1271    }
1272
1273    pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1274        let normalized = normalize_admin_setting_name(name)?;
1275        let mut settings = match self.get_admin_settings()? {
1276            JsonValue::Object(settings) => settings,
1277            _ => JsonMap::new(),
1278        };
1279        settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1280        save_json_object(admin_settings_path(&self.app_root), &settings)
1281    }
1282
1283    pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1284        if !reset_all && name.is_none() {
1285            bail!("resetting admin settings requires a setting name or reset-all");
1286        }
1287
1288        let mut settings = match self.get_admin_settings()? {
1289            JsonValue::Object(settings) => settings,
1290            _ => JsonMap::new(),
1291        };
1292
1293        if reset_all {
1294            for setting_name in SUPPORTED_ADMIN_SETTINGS {
1295                settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1296            }
1297        } else {
1298            settings.insert(
1299                normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1300                JsonValue::Bool(false),
1301            );
1302        }
1303
1304        save_json_object(admin_settings_path(&self.app_root), &settings)
1305    }
1306
1307    pub fn ensure_settings_files(&self) -> Result<()> {
1308        let user_settings = user_settings_path(&self.app_root);
1309        if !user_settings.exists() {
1310            save_json_object(user_settings, &JsonMap::new())?;
1311        }
1312
1313        let admin_settings = admin_settings_path(&self.app_root);
1314        if !admin_settings.exists() {
1315            let mut values = JsonMap::new();
1316            for name in SUPPORTED_ADMIN_SETTINGS {
1317                values.insert((*name).to_owned(), JsonValue::Bool(false));
1318            }
1319            save_json_object(admin_settings, &values)?;
1320        }
1321
1322        Ok(())
1323    }
1324
1325    pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1326        if self.source_mode == EffectiveSourceMode::SystemWingetDirect {
1327            return self.update_system_winget_sources(source_name);
1328        }
1329
1330        if self.mirrors_system_winget_sources() {
1331            self.refresh_system_winget_sources()?;
1332        }
1333
1334        let indexes = self.resolve_source_indexes(source_name)?;
1335        let mut results = Vec::new();
1336
1337        for index in indexes {
1338            let detail = match self.store.sources[index].kind {
1339                SourceKind::PreIndexed => self.update_preindexed(index)?,
1340                SourceKind::Rest => self.update_rest(index)?,
1341            };
1342
1343            results.push(SourceUpdateResult {
1344                name: self.store.sources[index].name.clone(),
1345                kind: self.store.sources[index].kind,
1346                detail,
1347            });
1348        }
1349
1350        self.save_store()?;
1351        Ok(results)
1352    }
1353
1354    pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1355        let located = self.search_located(query, SearchSemantics::Many)?;
1356        Ok(SearchResponse {
1357            matches: located.matches.into_iter().map(|item| item.display).collect(),
1358            warnings: located.warnings,
1359            truncated: located.truncated,
1360        })
1361    }
1362
1363    pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1364        let located = self.search_located(query, SearchSemantics::Many)?;
1365        let mut structured_documents = Vec::with_capacity(located.matches.len());
1366        for located in located.matches {
1367            let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1368            structured_documents.push(manifest_documents);
1369        }
1370
1371        Ok(collapse_structured_documents(&structured_documents))
1372    }
1373
1374    pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1375        if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1376            bail!("--include-unknown and --include-pinned require --upgrade-available");
1377        }
1378        if query.source.is_some()
1379            && !query.upgrade_only
1380            && query.query.is_none()
1381            && query.id.is_none()
1382            && query.name.is_none()
1383            && query.moniker.is_none()
1384            && query.tag.is_none()
1385            && query.command.is_none()
1386        {
1387            bail!("list --source currently requires a query or explicit filter");
1388        }
1389
1390        let source_refresh_warning = self.refresh_system_winget_sources_for_query();
1391
1392        let has_filter = list_query_needs_available_lookup(query);
1393        let needs_available = has_filter || query.upgrade_only;
1394
1395        let mut warnings = Vec::new();
1396        warnings.extend(source_refresh_warning);
1397        if !installed_package_discovery_supported() {
1398            warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1399        }
1400        let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1401
1402        // Plain `list` should still correlate installed packages back to their
1403        // catalog identities so callers see canonical package ids/source names.
1404        // Available-version lookups and loose name-only enrichment remain gated
1405        // by `needs_available` below.
1406        warnings.extend(self.correlate_installed_via_installed_db(&mut installed, query.source.as_deref())?);
1407        warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1408        warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1409
1410        if needs_available && has_filter {
1411            // Filtered lookup: search sources with the user's query for any
1412            // installed package not already resolved by identity.
1413            let available_query = package_query_from_list_query(query);
1414            let located = self.search_located(&available_query, SearchSemantics::Many)?;
1415            warnings.extend(located.warnings);
1416            let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1417            for package in &mut installed {
1418                if package.correlated.is_some() {
1419                    continue;
1420                }
1421                package.correlated =
1422                    correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1423            }
1424        } else if needs_available && !query.upgrade_only {
1425            // Plain unfiltered list: preserve legacy best-effort catalog
1426            // display/source enrichment. Bulk upgrade avoids this loose
1427            // name-only pass because winget only upgrades packages correlated
1428            // through stronger identity/normalization paths.
1429            warnings.extend(self.correlate_all_installed(&mut installed)?);
1430        }
1431
1432        if needs_available {
1433            // Enrich rows that correlated via the name-based fallback. Those
1434            // rows skipped the v2-index aMiV/aMaV pass that runs inside
1435            // `correlate_installed_via_index`, so they still carry the raw
1436            // ARP DisplayVersion (e.g. `JetBrains Rider 2025.3.0.1` showing
1437            // build `253.28294.112` instead of the marketing `2025.3.0.1`).
1438            // Also resolves MSIX placeholder names like
1439            // `ms-resource:appDisplayName` to the catalog's display name —
1440            // winget pulls these through Windows.Management.Deployment; the
1441            // catalog name is the parity-faithful substitute pinget has
1442            // available without taking a WinRT dependency.
1443            warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1444        }
1445
1446        // For `upgrade`, collapse multiple installed entries that map to the
1447        // same catalog package id (different ARP rows for side-by-side .NET
1448        // SDKs, several runtime versions of WindowsAppRuntime, the MSIX shim
1449        // alongside an ARP install of Edge, etc.). Keep the entry with the
1450        // highest installed_version so the upgrade comparison runs against
1451        // the user's newest install — matches winget's one-row-per-id output.
1452        if query.upgrade_only {
1453            installed = dedupe_correlated_for_upgrade(installed);
1454        }
1455
1456        let mut matches = installed
1457            .into_iter()
1458            .filter(|package| {
1459                list_package_matches(package, query)
1460                    && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1461            })
1462            .collect::<Vec<_>>();
1463
1464        matches.sort_by(|left, right| {
1465            list_sort_weight(left)
1466                .cmp(&list_sort_weight(right))
1467                .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1468                .then_with(|| left.local_id.cmp(&right.local_id))
1469        });
1470
1471        let pins = if query.upgrade_only {
1472            self.list_pins(query.source.as_deref())?
1473        } else {
1474            Vec::new()
1475        };
1476        let mut list_matches = matches
1477            .into_iter()
1478            .map(|package| list_match_from_installed(package, &self.store.sources))
1479            .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1480            .collect::<Vec<_>>();
1481        if !query.upgrade_only {
1482            list_matches = suppress_duplicate_available_versions(list_matches);
1483        }
1484        let truncated = if let Some(limit) = query.count {
1485            let was_truncated = list_matches.len() > limit;
1486            list_matches.truncate(limit);
1487            was_truncated
1488        } else {
1489            false
1490        };
1491
1492        Ok(ListResponse {
1493            matches: list_matches,
1494            warnings,
1495            truncated,
1496        })
1497    }
1498
1499    /// For unfiltered upgrade/list, search the entire available index and correlate
1500    /// against all installed packages. Skips packages already correlated via the
1501    /// v2 identity tables.
1502    fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1503        if installed.iter().all(|p| p.correlated.is_some()) {
1504            return Ok(Vec::new());
1505        }
1506        let all_query = PackageQuery {
1507            query: None,
1508            id: None,
1509            name: None,
1510            moniker: None,
1511            tag: None,
1512            command: None,
1513            source: None,
1514            count: Some(100_000), // fetch the entire index
1515            exact: false,
1516            version: None,
1517            channel: None,
1518            locale: None,
1519            installer_type: None,
1520            installer_architecture: None,
1521            platform: None,
1522            os_version: None,
1523            install_scope: None,
1524        };
1525        let located = self.search_located(&all_query, SearchSemantics::Many)?;
1526        let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1527
1528        for package in installed.iter_mut() {
1529            if package.correlated.is_some() {
1530                continue;
1531            }
1532            package.correlated = correlate_installed_package(package, &candidates, true);
1533        }
1534
1535        Ok(located.warnings)
1536    }
1537
1538    /// Uses winget's per-source installed tracking DB when available. This is
1539    /// an exact identity signal recorded by winget itself, and it preserves
1540    /// source order for sources that do not expose a preindexed identity table
1541    /// such as REST sources.
1542    fn correlate_installed_via_installed_db(
1543        &mut self,
1544        installed: &mut [InstalledPackage],
1545        requested_source: Option<&str>,
1546    ) -> Result<Vec<String>> {
1547        let source_indices = self
1548            .store
1549            .sources
1550            .iter()
1551            .enumerate()
1552            .filter(|(_, source)| {
1553                requested_source
1554                    .map(|name| source.name.eq_ignore_ascii_case(name))
1555                    .unwrap_or(true)
1556            })
1557            .map(|(index, _)| index)
1558            .collect::<Vec<_>>();
1559
1560        for source_index in source_indices {
1561            let source = self.source_clone(source_index);
1562            let installed_db_path = self.source_installed_db_path(&source);
1563            if !installed_db_path.exists() {
1564                continue;
1565            }
1566
1567            let connection = Self::open_sqlite_connection(installed_db_path)?;
1568            if !installed_db_identity_tables_present(&connection)? {
1569                continue;
1570            }
1571
1572            for package in installed.iter_mut() {
1573                if package.correlated.is_some() {
1574                    continue;
1575                }
1576                let Some((id, name, matched_by)) = lookup_installed_db_identity_match(&connection, package)? else {
1577                    continue;
1578                };
1579                let (id, name, moniker, version) = if source.kind == SourceKind::PreIndexed {
1580                    match self
1581                        .open_preindexed_connection(source_index)
1582                        .and_then(|index_connection| {
1583                            let Some(meta) = lookup_v2_metadata_by_id(&index_connection, &id)? else {
1584                                return Ok((id.clone(), name.clone(), None, None));
1585                            };
1586                            let (canonical_installed, anchored_latest) =
1587                                match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1588                                    Ok((entries, _)) => (
1589                                        map_arp_version_to_catalog(&entries, &package.installed_version)
1590                                            .or_else(|| {
1591                                                less_than_latest_arp_anchored_version(
1592                                                    &entries,
1593                                                    &package.installed_version,
1594                                                )
1595                                            })
1596                                            .or_else(|| {
1597                                                greater_than_latest_arp_anchored_version(
1598                                                    &entries,
1599                                                    &package.installed_version,
1600                                                    &meta.latest_version,
1601                                                )
1602                                            }),
1603                                        latest_arp_anchored_version(&entries),
1604                                    ),
1605                                    Err(_) => (None, None),
1606                                };
1607                            let version = match (&canonical_installed, anchored_latest) {
1608                                (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1609                                (Some(_), Some(anchored)) => Some(anchored),
1610                                (Some(_), None) => None,
1611                                _ => Some(meta.latest_version.clone()),
1612                            };
1613                            if let Some(version) = canonical_installed {
1614                                package.installed_version = version;
1615                                package.installed_version_canonical = true;
1616                            }
1617                            Ok((meta.id, meta.name, meta.moniker, version))
1618                        }) {
1619                        Ok(enriched) => enriched,
1620                        Err(_) => (id, name, None, None),
1621                    }
1622                } else {
1623                    (id, name, None, None)
1624                };
1625                package.correlated = Some(SearchMatch {
1626                    source_name: source.name.clone(),
1627                    source_kind: source.kind,
1628                    id,
1629                    name,
1630                    moniker,
1631                    version,
1632                    channel: None,
1633                    match_criteria: Some(matched_by.to_owned()),
1634                });
1635            }
1636        }
1637
1638        Ok(Vec::new())
1639    }
1640
1641    /// Correlates installed packages against the v2 pre-indexed catalog using
1642    /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's
1643    /// authoritative correlation path. Also rewrites `installed_version` to
1644    /// the catalog Version whose ARP range covers the ARP DisplayVersion, so
1645    /// packages whose ARP version is an MSI build number (e.g. .NET SDK,
1646    /// Python) compare against a meaningful catalog version. Packages without
1647    /// any of the three identifiers, or whose identifiers don't match a
1648    /// catalog entry, are left untouched for the name-based fallback.
1649    fn correlate_installed_via_index(
1650        &mut self,
1651        installed: &mut [InstalledPackage],
1652        requested_source: Option<&str>,
1653    ) -> Result<Vec<String>> {
1654        let source_indices: Vec<usize> = self
1655            .store
1656            .sources
1657            .iter()
1658            .enumerate()
1659            .filter(|(_, source)| {
1660                source.kind == SourceKind::PreIndexed
1661                    && match requested_source {
1662                        Some(name) => source.name.eq_ignore_ascii_case(name),
1663                        None => true,
1664                    }
1665            })
1666            .map(|(index, _)| index)
1667            .collect();
1668
1669        for source_index in source_indices {
1670            let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1671                let connection = match self.open_preindexed_connection(source_index) {
1672                    Ok(c) => c,
1673                    Err(_) => continue,
1674                };
1675                if !v2_identity_tables_present(&connection) {
1676                    continue;
1677                }
1678                let mut acc = Vec::new();
1679                for (idx, package) in installed.iter().enumerate() {
1680                    if package.correlated.is_some() {
1681                        continue;
1682                    }
1683                    let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1684                        continue;
1685                    };
1686                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1687                        continue;
1688                    };
1689                    acc.push((idx, meta, by));
1690                }
1691                acc
1692            };
1693
1694            let source = self.source_clone(source_index);
1695            for (idx, meta, by) in resolved {
1696                let (canonical_installed, anchored_latest) =
1697                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1698                        Ok((entries, _)) => (
1699                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1700                                .or_else(|| {
1701                                    less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1702                                })
1703                                .or_else(|| {
1704                                    greater_than_latest_arp_anchored_version(
1705                                        &entries,
1706                                        &installed[idx].installed_version,
1707                                        &meta.latest_version,
1708                                    )
1709                                }),
1710                            latest_arp_anchored_version(&entries),
1711                        ),
1712                        Err(_) => (None, None),
1713                    };
1714                // When the installed side was rebased to a catalog Version via
1715                // aMiV/aMaV, the available side must compare on the same scale:
1716                // prefer the latest ARP-anchored Version over `latest_version`,
1717                // which can be an internal/MSI build number (e.g. WinAppRuntime
1718                // 1.8's `8000.836.2153.0`) that would manufacture a phantom
1719                // upgrade against an installed canonical like `1.8.6`.
1720                let available_version = match (&canonical_installed, anchored_latest) {
1721                    (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1722                    (Some(_), Some(anchored)) => Some(anchored),
1723                    (Some(_), None) => None,
1724                    _ => Some(meta.latest_version),
1725                };
1726                let installed_pkg = &mut installed[idx];
1727                installed_pkg.correlated = Some(SearchMatch {
1728                    source_name: source.name.clone(),
1729                    source_kind: source.kind,
1730                    id: meta.id,
1731                    name: meta.name,
1732                    moniker: meta.moniker,
1733                    version: available_version,
1734                    channel: None,
1735                    match_criteria: Some(by.to_owned()),
1736                });
1737                if let Some(version) = canonical_installed {
1738                    installed_pkg.installed_version = version;
1739                    installed_pkg.installed_version_canonical = true;
1740                }
1741            }
1742        }
1743
1744        Ok(Vec::new())
1745    }
1746
1747    /// Mid-tier correlation that mirrors winget's ARP normalization path.
1748    /// For each installed package without a PFN/PC/UC identity match, the
1749    /// (DisplayName, Publisher) is run through the NameNormalizer and
1750    /// intersected against the v2 index's `norm_names2` and
1751    /// `norm_publishers2` tables. A single resulting `package_rowid`
1752    /// produces a correlation; ambiguous matches are skipped, matching
1753    /// winget's empty-Source behavior when normalization can't pick a
1754    /// winner.
1755    fn correlate_installed_by_normalized_identity(
1756        &mut self,
1757        installed: &mut [InstalledPackage],
1758        requested_source: Option<&str>,
1759    ) -> Result<Vec<String>> {
1760        let source_indices: Vec<usize> = self
1761            .store
1762            .sources
1763            .iter()
1764            .enumerate()
1765            .filter(|(_, source)| {
1766                source.kind == SourceKind::PreIndexed
1767                    && match requested_source {
1768                        Some(name) => source.name.eq_ignore_ascii_case(name),
1769                        None => true,
1770                    }
1771            })
1772            .map(|(index, _)| index)
1773            .collect();
1774
1775        for source_index in source_indices {
1776            let resolved: Vec<(usize, V2PackageMetadata)> = {
1777                let connection = match self.open_preindexed_connection(source_index) {
1778                    Ok(c) => c,
1779                    Err(_) => continue,
1780                };
1781                if !v2_normalized_identity_tables_present(&connection) {
1782                    continue;
1783                }
1784                let mut acc = Vec::new();
1785                for (idx, pkg) in installed.iter().enumerate() {
1786                    if pkg.correlated.is_some() {
1787                        continue;
1788                    }
1789                    // MSIX entries belong to the PFN-only path; skipping
1790                    // here mirrors `correlate_installed_package`'s
1791                    // hard-block on MSIX (their `ms-resource:` placeholder
1792                    // names normalize to garbage anyway).
1793                    if pkg.local_id.starts_with("MSIX\\") {
1794                        continue;
1795                    }
1796                    let Some(publisher) = pkg.publisher.as_deref() else {
1797                        continue;
1798                    };
1799                    let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1800                    if normalized_name.is_empty() {
1801                        continue;
1802                    }
1803                    let normalized_publisher = name_normalization::normalize_publisher(publisher);
1804                    if normalized_publisher.is_empty() {
1805                        continue;
1806                    }
1807                    let Some(rowid) =
1808                        lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1809                    else {
1810                        continue;
1811                    };
1812                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1813                        continue;
1814                    };
1815                    acc.push((idx, meta));
1816                }
1817                acc
1818            };
1819
1820            let source = self.source_clone(source_index);
1821            for (idx, meta) in resolved {
1822                let (canonical_installed, anchored_latest) =
1823                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1824                        Ok((entries, _)) => (
1825                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1826                                .or_else(|| {
1827                                    less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1828                                })
1829                                .or_else(|| {
1830                                    greater_than_latest_arp_anchored_version(
1831                                        &entries,
1832                                        &installed[idx].installed_version,
1833                                        &meta.latest_version,
1834                                    )
1835                                }),
1836                            latest_arp_anchored_version(&entries),
1837                        ),
1838                        Err(_) => (None, None),
1839                    };
1840                let available_version = match (&canonical_installed, anchored_latest) {
1841                    (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1842                    (Some(_), Some(anchored)) => Some(anchored),
1843                    (Some(_), None) => None,
1844                    _ => Some(meta.latest_version),
1845                };
1846                let installed_pkg = &mut installed[idx];
1847                installed_pkg.correlated = Some(SearchMatch {
1848                    source_name: source.name.clone(),
1849                    source_kind: source.kind,
1850                    id: meta.id,
1851                    name: meta.name,
1852                    moniker: meta.moniker,
1853                    version: available_version,
1854                    channel: None,
1855                    match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1856                });
1857                if let Some(version) = canonical_installed {
1858                    installed_pkg.installed_version = version;
1859                    installed_pkg.installed_version_canonical = true;
1860                }
1861            }
1862        }
1863
1864        Ok(Vec::new())
1865    }
1866
1867    /// Runs after both correlation paths to enrich already-correlated rows
1868    /// with information that the identity path applies inline but that
1869    /// name-based correlation skips:
1870    ///   • The MSIX-placeholder name fix — when an MSIX entry's installed
1871    ///     name is an unresolved Windows resource string (`ms-resource:...`)
1872    ///     winget renders the catalog's `PackageName` instead. We do the
1873    ///     same so display-name comparisons match.
1874    ///   • The aMiV/aMaV version remap — `JetBrains.Rider` has no
1875    ///     ProductCode/PFN/UpgradeCode so it correlates by name; without
1876    ///     this pass its installed version stays as the ARP build number
1877    ///     `253.28294.112` instead of the catalog Version `2025.3.0.1`.
1878    fn enrich_correlated_via_index(
1879        &mut self,
1880        installed: &mut [InstalledPackage],
1881        requested_source: Option<&str>,
1882    ) -> Result<Vec<String>> {
1883        // Cheap pass first — no SQL required.
1884        for package in installed.iter_mut() {
1885            apply_msix_resource_string_name_fix(package);
1886        }
1887
1888        // Expensive pass — load versionData for rows whose installed_version
1889        // wasn't remapped earlier.
1890        let source_indices: Vec<usize> = self
1891            .store
1892            .sources
1893            .iter()
1894            .enumerate()
1895            .filter(|(_, source)| {
1896                source.kind == SourceKind::PreIndexed
1897                    && match requested_source {
1898                        Some(name) => source.name.eq_ignore_ascii_case(name),
1899                        None => true,
1900                    }
1901            })
1902            .map(|(index, _)| index)
1903            .collect();
1904
1905        for source_index in source_indices {
1906            let needs: Vec<(usize, V2PackageMetadata)> = {
1907                let connection = match self.open_preindexed_connection(source_index) {
1908                    Ok(c) => c,
1909                    Err(_) => continue,
1910                };
1911                if !v2_identity_tables_present(&connection) {
1912                    continue;
1913                }
1914                let mut acc = Vec::new();
1915                for (idx, pkg) in installed.iter().enumerate() {
1916                    let Some(candidate) = pkg.correlated.as_ref() else {
1917                        continue;
1918                    };
1919                    // Stay within the source the row was correlated to —
1920                    // looking up `JetBrains.Rider` in a different catalog
1921                    // would either miss or pull the wrong package's range.
1922                    if !candidate
1923                        .source_name
1924                        .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1925                    {
1926                        continue;
1927                    }
1928                    let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1929                        continue;
1930                    };
1931                    acc.push((idx, meta));
1932                }
1933                acc
1934            };
1935
1936            let source = self.source_clone(source_index);
1937            for (idx, meta) in needs {
1938                // Load versionData once: we may need it for both the
1939                // aMiV/aMaV remap (only if installed_version isn't already
1940                // canonical) and to locate the latest manifest for the
1941                // RequireExplicitUpgrade flag.
1942                let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1943                    Ok((entries, _)) => entries,
1944                    Err(_) => continue,
1945                };
1946
1947                if !installed[idx].installed_version_canonical {
1948                    let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1949                        .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version))
1950                        .or_else(|| {
1951                            greater_than_latest_arp_anchored_version(
1952                                &entries,
1953                                &installed[idx].installed_version,
1954                                &meta.latest_version,
1955                            )
1956                        });
1957                    let anchored_latest = latest_arp_anchored_version(&entries);
1958                    if let Some(canonical) = canonical_installed {
1959                        let greater_than_latest = is_greater_than_version_marker(&canonical);
1960                        installed[idx].installed_version = canonical;
1961                        installed[idx].installed_version_canonical = true;
1962                        if let Some(correlated) = installed[idx].correlated.as_mut() {
1963                            correlated.version = if greater_than_latest { None } else { anchored_latest };
1964                        }
1965                    }
1966                }
1967
1968                // RequireExplicitUpgrade is set on the installer manifest
1969                // for the latest catalog version. winget hides these from
1970                // bulk `upgrade` (Edge, Steam, Discord and others). We
1971                // peek at the first versionData entry (latest by sort
1972                // order) and read its manifest to mirror that filtering.
1973                if let Some(latest) = entries.first() {
1974                    let bytes_result = self.get_cached_source_file(
1975                        "V2_M",
1976                        &source,
1977                        &latest.manifest_relative_path,
1978                        Some(latest.manifest_hash.as_str()),
1979                    );
1980                    if let Ok(bytes) = bytes_result
1981                        && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1982                    {
1983                        installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1984                        installed[idx].correlated_lacks_compatible_installer =
1985                            !manifest_has_compatible_installer(&manifest);
1986                    }
1987                }
1988            }
1989        }
1990
1991        Ok(Vec::new())
1992    }
1993
1994    pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1995        let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1996        let versions = self.versions_for_match(&located, query)?;
1997        Ok(VersionsResult {
1998            package: located.display,
1999            versions,
2000            warnings,
2001        })
2002    }
2003
2004    pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
2005        let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
2006        let versions = self.versions_for_match(&located, query)?;
2007        Ok(VersionsResult {
2008            package: located.display,
2009            versions,
2010            warnings,
2011        })
2012    }
2013
2014    pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
2015        let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
2016        let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
2017        let selected_installer = select_installer(&manifest.installers, query);
2018
2019        Ok(ShowResult {
2020            package: located.display,
2021            manifest,
2022            selected_installer,
2023            cached_files,
2024            warnings,
2025            manifest_documents,
2026        })
2027    }
2028
2029    pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
2030        let (located, warnings) = self.find_single_match(query)?;
2031        let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
2032
2033        Ok(CacheWarmResult {
2034            package: located.display,
2035            cached_files,
2036            warnings,
2037        })
2038    }
2039
2040    // ── Pin management ──
2041
2042    pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
2043        let db_path = pins_db_path(&self.app_root);
2044        if !db_path.exists() {
2045            return Ok(Vec::new());
2046        }
2047        let conn = Self::open_sqlite_connection(db_path)?;
2048        let pin_type_column = match resolve_pin_type_column(&conn)? {
2049            Some(column) => column,
2050            None => return Ok(Vec::new()),
2051        };
2052        let sql = if source_id.is_some() {
2053            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
2054        } else {
2055            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
2056        };
2057        let params = if let Some(source_id) = source_id {
2058            vec![SqlValue::Text(source_id.to_owned())]
2059        } else {
2060            Vec::new()
2061        };
2062        query_rows(&conn, &sql, params, |row| {
2063            let pin_type_int = row_i64(row, 3)?;
2064            Ok(PinRecord {
2065                package_id: row_string(row, 0)?,
2066                version: row_string(row, 1)?,
2067                source_id: row_string(row, 2)?,
2068                pin_type: decode_pin_type(pin_type_int),
2069            })
2070        })
2071    }
2072
2073    pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
2074        let db_path = pins_db_path(&self.app_root);
2075        let conn = Self::open_sqlite_connection(db_path)?;
2076        execute_batch_sql(
2077            &conn,
2078            "CREATE TABLE IF NOT EXISTS pin (
2079                package_id TEXT NOT NULL,
2080                version TEXT NOT NULL DEFAULT '*',
2081                source_id TEXT NOT NULL DEFAULT '',
2082                type INTEGER NOT NULL DEFAULT 0,
2083                PRIMARY KEY (package_id, source_id)
2084            )",
2085        )?;
2086        let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
2087        let type_int: i64 = match pin_type {
2088            PinType::Pinning => 2,
2089            PinType::Blocking => 4,
2090            PinType::Gating => 3,
2091        };
2092        execute_sql(
2093            &conn,
2094            &format!(
2095                "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
2096            ),
2097            vec![
2098                SqlValue::Text(package_id.to_owned()),
2099                SqlValue::Text(version.to_owned()),
2100                SqlValue::Text(source_id.to_owned()),
2101                SqlValue::Integer(type_int),
2102            ],
2103        )?;
2104        conn.cacheflush().context("failed to flush pin database")?;
2105        Ok(())
2106    }
2107
2108    pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
2109        let db_path = pins_db_path(&self.app_root);
2110        if !db_path.exists() {
2111            return Ok(false);
2112        }
2113        let conn = Self::open_sqlite_connection(db_path)?;
2114        let count = if let Some(source_id) = source_id {
2115            execute_sql(
2116                &conn,
2117                "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
2118                vec![
2119                    SqlValue::Text(package_id.to_owned()),
2120                    SqlValue::Text(source_id.to_owned()),
2121                ],
2122            )?
2123        } else {
2124            execute_sql(
2125                &conn,
2126                "DELETE FROM pin WHERE package_id = ?1",
2127                vec![SqlValue::Text(package_id.to_owned())],
2128            )?
2129        };
2130        conn.cacheflush().context("failed to flush pin database")?;
2131        Ok(count > 0)
2132    }
2133
2134    pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
2135        let db_path = pins_db_path(&self.app_root);
2136        if !db_path.exists() {
2137            return Ok(());
2138        }
2139
2140        if let Some(source_id) = source_id {
2141            let conn = Self::open_sqlite_connection(db_path)?;
2142            execute_sql(
2143                &conn,
2144                "DELETE FROM pin WHERE source_id = ?1",
2145                vec![SqlValue::Text(source_id.to_owned())],
2146            )?;
2147            conn.cacheflush().context("failed to flush pin database")?;
2148        } else {
2149            fs::remove_file(&db_path)?;
2150        }
2151        Ok(())
2152    }
2153
2154    fn save_store(&self) -> Result<()> {
2155        if self.mirrors_system_winget_sources() {
2156            save_system_winget_mirror_store(&self.app_root, &self.store)
2157        } else {
2158            save_store(&self.app_root, &self.store)
2159        }
2160    }
2161
2162    fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
2163        source_state_dir_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2164    }
2165
2166    fn source_installed_db_path(&self, source: &SourceRecord) -> PathBuf {
2167        source_installed_db_path_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2168    }
2169
2170    fn preindexed_package_path(&self, source: &SourceRecord) -> PathBuf {
2171        self.source_state_dir(source).join("source.msix")
2172    }
2173
2174    fn preindexed_index_path(&self, source: &SourceRecord) -> PathBuf {
2175        self.source_state_dir(source).join("index.db")
2176    }
2177
2178    fn rest_information_cache_path(&self, source: &SourceRecord) -> PathBuf {
2179        self.source_state_dir(source).join("rest-information.json")
2180    }
2181
2182    fn rest_manifest_cache_path(
2183        &self,
2184        source: &SourceRecord,
2185        package_id: &str,
2186        version: &str,
2187        channel: &str,
2188    ) -> PathBuf {
2189        let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
2190        let digest = sha256_hex(key.as_bytes());
2191        cache_root(&self.app_root)
2192            .join("REST_M")
2193            .join(sanitize_path_segment(&source.identifier))
2194            .join(format!("{digest}.json"))
2195    }
2196
2197    // ── Install / download ──
2198
2199    pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
2200        self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
2201    }
2202
2203    pub fn download_installer_for_request(
2204        &mut self,
2205        request: &InstallRequest,
2206        download_dir: &Path,
2207    ) -> Result<DownloadOutput> {
2208        let fetched = self.fetch_installer_for_request(request, download_dir)?;
2209        let manifest_yaml =
2210            build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
2211                .context("failed to render manifest YAML")?;
2212        let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
2213        let manifest_path = download_dir.join(&manifest_filename);
2214        fs::write(&manifest_path, manifest_yaml.as_bytes())
2215            .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
2216
2217        Ok(DownloadOutput {
2218            manifest: fetched.manifest,
2219            installer_path: fetched.installer_path,
2220            manifest_path,
2221        })
2222    }
2223
2224    /// Fetches the installer file without emitting the YAML manifest. Used by
2225    /// the install flow, which downloads into a temp dir and only needs the
2226    /// installer path — emitting a manifest there would litter the temp dir
2227    /// and turn a benign YAML-write failure into an installation failure.
2228    fn fetch_installer_for_request(
2229        &mut self,
2230        request: &InstallRequest,
2231        download_dir: &Path,
2232    ) -> Result<FetchedInstaller> {
2233        let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
2234        let installer = select_installer(&manifest.installers, &request.query)
2235            .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
2236        let url = installer
2237            .url
2238            .as_deref()
2239            .ok_or_else(|| anyhow!("Installer has no URL"))?;
2240
2241        fs::create_dir_all(download_dir)?;
2242        let filename = request.rename.as_deref().unwrap_or_else(|| {
2243            url.rsplit('/')
2244                .next()
2245                .unwrap_or("installer")
2246                .split('?')
2247                .next()
2248                .unwrap_or("installer")
2249        });
2250        let dest = download_dir.join(filename);
2251
2252        self.emit_install_progress(InstallProgress::DownloadingInstaller {
2253            url: url.to_owned(),
2254            path: dest.clone(),
2255        });
2256        let actual_hash = download_installer_to_file(&self.user_agent, url, &dest)?;
2257
2258        self.emit_install_progress(InstallProgress::VerifyingInstaller { path: dest.clone() });
2259        if let Err(error) =
2260            verify_installer_hash_hex(installer.sha256.as_deref(), &actual_hash, request.ignore_security_hash)
2261        {
2262            let _ = fs::remove_file(&dest);
2263            return Err(error);
2264        }
2265
2266        Ok(FetchedInstaller {
2267            manifest,
2268            manifest_documents,
2269            installer,
2270            installer_path: dest,
2271        })
2272    }
2273
2274    pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2275        let mut request = InstallRequest::new(query.clone());
2276        request.mode = if silent {
2277            InstallerMode::Silent
2278        } else {
2279            InstallerMode::SilentWithProgress
2280        };
2281        self.install_request(&request)
2282    }
2283
2284    pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
2285        let mut request = InstallRequest::new(query.clone());
2286        request.mode = mode;
2287        self.install_request(&request)
2288    }
2289
2290    pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
2291        self.emit_install_progress(InstallProgress::ResolvingManifest);
2292        let manifest = self.resolve_manifest_for_install(request)?;
2293        self.emit_install_progress(InstallProgress::ResolvedManifest {
2294            package_id: manifest.id.clone(),
2295            version: manifest.version.clone(),
2296        });
2297        if !package_actions_supported() {
2298            let installer_type = select_installer(&manifest.installers, &request.query)
2299                .and_then(|installer| installer.installer_type)
2300                .unwrap_or_else(|| "install".to_owned())
2301                .to_lowercase();
2302            return Ok(unsupported_action_result(
2303                manifest.id.clone(),
2304                manifest.version.clone(),
2305                installer_type,
2306                INSTALL_UNSUPPORTED_WARNING,
2307            ));
2308        }
2309
2310        self.emit_install_progress(InstallProgress::CheckingInstalled);
2311        let existing_match = self.find_installed_package_for_install(request, &manifest)?;
2312        if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
2313            return Ok(no_op_result);
2314        }
2315
2316        Self::ensure_package_agreements_accepted(&manifest, request)?;
2317        self.emit_install_progress(InstallProgress::InstallingDependencies);
2318        self.install_dependencies(&manifest, request, &mut HashSet::new())?;
2319
2320        if request.dependencies_only {
2321            return Ok(InstallResult {
2322                package_id: manifest.id.clone(),
2323                version: manifest.version.clone(),
2324                installer_path: PathBuf::new(),
2325                installer_type: "dependencies".to_owned(),
2326                exit_code: 0,
2327                success: true,
2328                no_op: false,
2329                warnings: Vec::new(),
2330            });
2331        }
2332
2333        self.emit_install_progress(InstallProgress::SelectingInstaller);
2334        let installer = select_installer(&manifest.installers, &request.query)
2335            .ok_or_else(|| anyhow!("No applicable installer found"))?;
2336
2337        if request.uninstall_previous {
2338            let mut uninstall_request = UninstallRequest::new(PackageQuery {
2339                id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2340                query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
2341                source: request.query.source.clone(),
2342                exact: true,
2343                version: request.query.version.clone(),
2344                ..PackageQuery::default()
2345            });
2346            uninstall_request.product_code = installer.product_code.clone();
2347            uninstall_request.mode = InstallerMode::Silent;
2348            uninstall_request.all_versions = true;
2349            uninstall_request.force = true;
2350            let _ = self.uninstall_request(&uninstall_request);
2351        }
2352
2353        let temp_dir = std::env::temp_dir().join("pinget-install");
2354        let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
2355
2356        let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
2357
2358        self.emit_install_progress(InstallProgress::StartingInstaller {
2359            installer_type: installer_type.clone(),
2360            path: installer_path.clone(),
2361        });
2362        self.emit_install_progress(InstallProgress::WaitingForInstaller {
2363            installer_type: installer_type.clone(),
2364        });
2365        let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
2366        self.emit_install_progress(InstallProgress::InstallerFinished { exit_code });
2367
2368        Ok(InstallResult {
2369            package_id: manifest.id.clone(),
2370            version: manifest.version.clone(),
2371            installer_path,
2372            installer_type,
2373            exit_code,
2374            success: exit_code == 0,
2375            no_op: false,
2376            warnings: Vec::new(),
2377        })
2378    }
2379
2380    fn find_installed_package_for_install(
2381        &mut self,
2382        request: &InstallRequest,
2383        manifest: &Manifest,
2384    ) -> Result<Option<ListMatch>> {
2385        let installed_matches = self.list(&ListQuery {
2386            query: request.query.query.clone(),
2387            id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2388            name: request.query.name.clone(),
2389            moniker: request.query.moniker.clone(),
2390            tag: None,
2391            command: None,
2392            product_code: None,
2393            version: None,
2394            source: request.query.source.clone(),
2395            count: Some(100),
2396            exact: request.query.exact || !manifest.id.trim().is_empty(),
2397            install_scope: request.query.install_scope.clone(),
2398            upgrade_only: false,
2399            include_unknown: false,
2400            include_pinned: false,
2401        })?;
2402
2403        Ok(installed_matches.matches.into_iter().find(|candidate| {
2404            candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
2405        }))
2406    }
2407
2408    fn create_install_no_op_result(
2409        request: &InstallRequest,
2410        manifest: &Manifest,
2411        existing_match: Option<&ListMatch>,
2412    ) -> Option<InstallResult> {
2413        let existing_match = existing_match?;
2414
2415        if request.no_upgrade {
2416            return Some(InstallResult {
2417                package_id: manifest.id.clone(),
2418                version: existing_match.installed_version.clone(),
2419                installer_path: PathBuf::new(),
2420                installer_type: "install".to_owned(),
2421                exit_code: 0,
2422                success: true,
2423                no_op: true,
2424                warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
2425            });
2426        }
2427
2428        if !request.force
2429            && !existing_match.installed_version.trim().is_empty()
2430            && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
2431        {
2432            return Some(InstallResult {
2433                package_id: manifest.id.clone(),
2434                version: existing_match.installed_version.clone(),
2435                installer_path: PathBuf::new(),
2436                installer_type: "install".to_owned(),
2437                exit_code: 0,
2438                success: true,
2439                no_op: true,
2440                warnings: vec![
2441                    "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
2442                ],
2443            });
2444        }
2445
2446        None
2447    }
2448
2449    pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
2450        if request.manifest_path.is_none() && !package_actions_supported() {
2451            let package_id = request
2452                .query
2453                .id
2454                .clone()
2455                .or_else(|| request.query.name.clone())
2456                .or_else(|| request.query.moniker.clone())
2457                .or_else(|| request.query.query.clone())
2458                .or_else(|| request.product_code.clone())
2459                .unwrap_or_else(|| "repair".to_owned());
2460            return Ok(unsupported_action_result(
2461                package_id,
2462                request.query.version.clone().unwrap_or_default(),
2463                "repair".to_owned(),
2464                REPAIR_UNSUPPORTED_WARNING,
2465            ));
2466        }
2467
2468        let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2469        let installed_match = if request.manifest_path.is_some() {
2470            None
2471        } else {
2472            let installed = self.list(&Self::create_repair_list_query(request))?;
2473            warnings.extend(installed.warnings);
2474            if installed.matches.is_empty() {
2475                bail!("No installed package matched the supplied repair query.");
2476            }
2477            if installed.matches.len() > 1 {
2478                bail!("Multiple installed packages matched the supplied repair query.");
2479            }
2480            installed.matches.into_iter().next()
2481        };
2482
2483        let mut result =
2484            self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2485        warnings.extend(result.warnings);
2486        result.warnings = warnings;
2487        Ok(result)
2488    }
2489
2490    pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2491        let mut request = UninstallRequest::new(query.clone());
2492        request.mode = if silent {
2493            InstallerMode::Silent
2494        } else {
2495            InstallerMode::SilentWithProgress
2496        };
2497        self.uninstall_request(&request)
2498    }
2499
2500    pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2501        if !package_actions_supported() {
2502            let (package_id, version) = if let Some(path) = &request.manifest_path {
2503                let manifest = Self::load_manifest_from_path(path)?;
2504                (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2505            } else {
2506                (
2507                    request
2508                        .query
2509                        .id
2510                        .clone()
2511                        .or_else(|| request.query.query.clone())
2512                        .or_else(|| request.query.name.clone())
2513                        .or_else(|| request.query.moniker.clone())
2514                        .or_else(|| request.product_code.clone())
2515                        .unwrap_or_else(|| "unknown".to_owned()),
2516                    request.query.version.clone().unwrap_or_default(),
2517                )
2518            };
2519
2520            return Ok(unsupported_action_result(
2521                package_id,
2522                version,
2523                "uninstall",
2524                UNINSTALL_UNSUPPORTED_WARNING,
2525            ));
2526        }
2527
2528        let matches = self.resolve_uninstall_matches(request)?;
2529        let mut exit_code = 0;
2530
2531        for installed in &matches {
2532            exit_code = uninstall_package(installed, request)?;
2533            if exit_code != 0 && !request.all_versions {
2534                break;
2535            }
2536        }
2537
2538        let installed = matches
2539            .first()
2540            .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2541
2542        Ok(InstallResult {
2543            package_id: installed.id.clone(),
2544            version: installed.installed_version.clone(),
2545            installer_path: PathBuf::new(),
2546            installer_type: "uninstall".to_owned(),
2547            exit_code,
2548            success: exit_code == 0,
2549            no_op: false,
2550            warnings: Vec::new(),
2551        })
2552    }
2553
2554    fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2555        self.resolve_manifest_and_documents_for_install(request)
2556            .map(|(manifest, _)| manifest)
2557    }
2558
2559    fn resolve_manifest_and_documents_for_install(
2560        &mut self,
2561        request: &InstallRequest,
2562    ) -> Result<(Manifest, JsonValue)> {
2563        if let Some(path) = &request.manifest_path {
2564            return Self::load_manifest_from_path_with_documents(path);
2565        }
2566
2567        let (located, _warnings) = self.find_single_match(&request.query)?;
2568        let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2569        Ok((manifest, documents))
2570    }
2571
2572    fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2573        if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2574            bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2575        }
2576        Ok(())
2577    }
2578
2579    fn install_dependencies(
2580        &mut self,
2581        manifest: &Manifest,
2582        request: &InstallRequest,
2583        visited: &mut HashSet<String>,
2584    ) -> Result<()> {
2585        if request.skip_dependencies {
2586            return Ok(());
2587        }
2588
2589        let dependency_ids = manifest
2590            .package_dependencies
2591            .iter()
2592            .chain(
2593                manifest
2594                    .installers
2595                    .iter()
2596                    .flat_map(|installer| installer.package_dependencies.iter()),
2597            )
2598            .filter(|value| !value.trim().is_empty())
2599            .cloned()
2600            .collect::<BTreeSet<_>>();
2601
2602        for dependency_id in dependency_ids {
2603            if !visited.insert(dependency_id.clone()) {
2604                continue;
2605            }
2606
2607            let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2608            self.install_request(&dependency_request)?;
2609        }
2610
2611        Ok(())
2612    }
2613
2614    fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2615        let mut dependency_request = InstallRequest::new(PackageQuery {
2616            id: Some(dependency_id),
2617            source: request
2618                .dependency_source
2619                .clone()
2620                .or_else(|| request.query.source.clone()),
2621            exact: true,
2622            ..PackageQuery::default()
2623        });
2624        dependency_request.mode = match request.mode {
2625            InstallerMode::Interactive => InstallerMode::Interactive,
2626            InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2627        };
2628        dependency_request.accept_package_agreements = request.accept_package_agreements;
2629        dependency_request.no_upgrade = true;
2630        dependency_request.ignore_security_hash = request.ignore_security_hash;
2631        dependency_request.dependency_source = request.dependency_source.clone();
2632        dependency_request
2633    }
2634
2635    fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2636        ListQuery {
2637            query: request.query.query.clone(),
2638            id: request.query.id.clone(),
2639            name: request.query.name.clone(),
2640            moniker: request.query.moniker.clone(),
2641            product_code: request.product_code.clone(),
2642            version: request.query.version.clone(),
2643            source: request.query.source.clone(),
2644            count: Some(100),
2645            exact: request.query.exact,
2646            install_scope: request.query.install_scope.clone(),
2647            ..ListQuery::default()
2648        }
2649    }
2650
2651    fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2652        let mut query = request.query.clone();
2653        query.query = if installed_match.is_none() {
2654            request.query.query.clone()
2655        } else {
2656            None
2657        };
2658        query.id = installed_match
2659            .map(|item| item.id.clone())
2660            .or_else(|| request.query.id.clone());
2661        query.name = if installed_match.is_none() {
2662            request.query.name.clone()
2663        } else {
2664            None
2665        };
2666        query.moniker = if installed_match.is_none() {
2667            request.query.moniker.clone()
2668        } else {
2669            None
2670        };
2671        query.source = request
2672            .query
2673            .source
2674            .clone()
2675            .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2676        query.exact = true;
2677        query.version = request
2678            .query
2679            .version
2680            .clone()
2681            .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2682
2683        InstallRequest {
2684            query,
2685            manifest_path: request.manifest_path.clone(),
2686            mode: request.mode,
2687            log_path: request.log_path.clone(),
2688            custom: None,
2689            override_args: None,
2690            install_location: None,
2691            skip_dependencies: false,
2692            dependencies_only: false,
2693            accept_package_agreements: request.accept_package_agreements,
2694            force: true,
2695            rename: None,
2696            uninstall_previous: false,
2697            ignore_security_hash: request.ignore_security_hash,
2698            dependency_source: None,
2699            no_upgrade: false,
2700        }
2701    }
2702
2703    fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2704        let mut effective_query = request.query.clone();
2705        let mut effective_product_code = request.product_code.clone();
2706
2707        if let Some(path) = &request.manifest_path {
2708            let manifest = Self::load_manifest_from_path(path)?;
2709            effective_query = PackageQuery {
2710                query: Some(manifest.id.clone()),
2711                id: Some(manifest.id.clone()),
2712                name: Some(manifest.name.clone()),
2713                exact: true,
2714                version: effective_query.version.clone().or(Some(manifest.version)),
2715                ..PackageQuery::default()
2716            };
2717            effective_product_code = effective_product_code.or_else(|| {
2718                manifest
2719                    .installers
2720                    .iter()
2721                    .find_map(|installer| installer.product_code.clone())
2722            });
2723        }
2724
2725        let list_query = ListQuery {
2726            query: effective_query.query.clone(),
2727            id: effective_query.id.clone(),
2728            name: effective_query.name.clone(),
2729            moniker: effective_query.moniker.clone(),
2730            tag: None,
2731            command: None,
2732            product_code: effective_product_code,
2733            version: effective_query.version.clone(),
2734            source: effective_query.source.clone(),
2735            count: if request.all_versions { None } else { Some(100) },
2736            exact: effective_query.exact,
2737            install_scope: effective_query.install_scope,
2738            upgrade_only: false,
2739            include_unknown: false,
2740            include_pinned: false,
2741        };
2742
2743        let list_result = self.list(&list_query)?;
2744        if list_result.matches.is_empty() {
2745            bail!("No installed package found matching the query");
2746        }
2747        if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2748            bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2749        }
2750
2751        Ok(if request.all_versions {
2752            list_result.matches
2753        } else {
2754            vec![list_result.matches[0].clone()]
2755        })
2756    }
2757
2758    fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2759        Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2760    }
2761
2762    fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2763        let resolved = resolve_manifest_path(manifest_path)?;
2764        let bytes =
2765            fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2766        parse_yaml_manifest_bundle(&bytes)
2767    }
2768
2769    fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2770        self.find_single_match_with_semantics(query, SearchSemantics::Single)
2771    }
2772
2773    fn find_single_match_for_show(
2774        &mut self,
2775        query: &PackageQuery,
2776        semantics: SearchSemantics,
2777    ) -> Result<(LocatedMatch, Vec<String>)> {
2778        let located = self.search_located(query, semantics)?;
2779        if self.should_retry_show_across_system_winget_sources(query, &located) {
2780            let mut fallback_query = query.clone();
2781            fallback_query.source = None;
2782            return single_match_from_located(self.search_located(&fallback_query, semantics)?, false);
2783        }
2784
2785        single_match_from_located(located, query.source.is_some())
2786    }
2787
2788    fn should_retry_show_across_system_winget_sources(
2789        &self,
2790        query: &PackageQuery,
2791        located: &SearchLocatedResult,
2792    ) -> bool {
2793        should_retry_show_across_system_winget_sources(self.uses_system_winget_sources(), query, located)
2794    }
2795
2796    fn find_single_match_with_semantics(
2797        &mut self,
2798        query: &PackageQuery,
2799        semantics: SearchSemantics,
2800    ) -> Result<(LocatedMatch, Vec<String>)> {
2801        let located = self.search_located(query, semantics)?;
2802        single_match_from_located(located, query.source.is_some())
2803    }
2804
2805    fn search_located(&mut self, query: &PackageQuery, semantics: SearchSemantics) -> Result<SearchLocatedResult> {
2806        let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2807        let mut matches = Vec::new();
2808        let mut warnings = Vec::new();
2809        let mut failures = Vec::new();
2810        let mut truncated = false;
2811
2812        for index in indexes {
2813            match self.search_source(index, query, semantics) {
2814                Ok(mut source_matches) => {
2815                    truncated |= source_matches.truncated;
2816                    matches.append(&mut source_matches.matches);
2817                }
2818                Err(error) => {
2819                    let source_name = self.store.sources[index].name.clone();
2820                    let message = format!("{error:#}");
2821                    warnings.push(format!(
2822                        "Failed when searching source '{}'; results will not be included: {}",
2823                        source_name, message
2824                    ));
2825                    failures.push(SourceSearchFailure { source_name, message });
2826                }
2827            }
2828        }
2829
2830        if semantics == SearchSemantics::Many {
2831            matches.sort_by(|left, right| {
2832                search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2833            });
2834            let limit = max_results(query);
2835            if matches.len() > limit {
2836                truncated = true;
2837                matches.truncate(limit);
2838            }
2839        }
2840
2841        Ok(SearchLocatedResult {
2842            matches,
2843            warnings,
2844            failures,
2845            truncated,
2846        })
2847    }
2848
2849    fn search_source(
2850        &mut self,
2851        source_index: usize,
2852        query: &PackageQuery,
2853        semantics: SearchSemantics,
2854    ) -> Result<SearchSourceMatches> {
2855        match self.store.sources[source_index].kind {
2856            SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2857            SourceKind::Rest => self.search_rest(source_index, query, semantics),
2858        }
2859    }
2860
2861    fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2862        match &located.locator {
2863            MatchLocator::PreIndexedV1 { package_rowid } => {
2864                let source = self.source_clone(located.source_index);
2865                let connection = self.open_preindexed_connection(located.source_index)?;
2866                let versions = query_v1_versions(&connection, *package_rowid)?;
2867                let mut keys = versions
2868                    .into_iter()
2869                    .map(|row| VersionKey {
2870                        version: row.version,
2871                        channel: row.channel,
2872                    })
2873                    .collect::<Vec<_>>();
2874                sort_versions_desc(&mut keys);
2875                if keys.is_empty() {
2876                    bail!("no versions found for {} in {}", located.display.id, source.name);
2877                }
2878                Ok(keys)
2879            }
2880            MatchLocator::PreIndexedV2 {
2881                package_rowid,
2882                package_hash,
2883            } => {
2884                let source = self.source_clone(located.source_index);
2885                let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2886                let mut keys = entries
2887                    .into_iter()
2888                    .map(|entry| VersionKey {
2889                        version: entry.version,
2890                        channel: String::new(),
2891                    })
2892                    .collect::<Vec<_>>();
2893                sort_versions_desc(&mut keys);
2894                Ok(keys)
2895            }
2896            MatchLocator::Rest { versions, .. } => {
2897                let mut keys = versions.clone();
2898                sort_versions_desc(&mut keys);
2899                Ok(keys)
2900            }
2901        }
2902    }
2903
2904    fn manifest_for_match(
2905        &mut self,
2906        located: &LocatedMatch,
2907        query: &PackageQuery,
2908    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2909        self.manifest_for_match_with_refresh(located, query, true)
2910    }
2911
2912    fn manifest_for_match_with_refresh(
2913        &mut self,
2914        located: &LocatedMatch,
2915        query: &PackageQuery,
2916        allow_explicit_version_refresh: bool,
2917    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2918        match &located.locator {
2919            MatchLocator::PreIndexedV1 { package_rowid } => {
2920                let source = self.source_clone(located.source_index);
2921                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2922                let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2923                let selected = match select_v1_version(
2924                    &versions,
2925                    query.version.as_deref(),
2926                    query.channel.as_deref(),
2927                    &located.display.id,
2928                    &source.name,
2929                ) {
2930                    Ok(selected) => selected,
2931                    Err(error)
2932                        if allow_explicit_version_refresh
2933                            && query.version.is_some()
2934                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2935                    {
2936                        let open_refresh = opened.refresh.clone();
2937                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2938                            return missing_error_after_open_refresh(error, &open_refresh);
2939                        }
2940                        if self.has_recent_successful_preindexed_refresh_attempt(
2941                            &self.preindexed_refresh_lock_key(&source),
2942                        ) {
2943                            return Err(error);
2944                        }
2945                        drop(opened);
2946                        return self.refresh_preindexed_and_retry_manifest(
2947                            located.source_index,
2948                            &located.display,
2949                            query,
2950                            error,
2951                        );
2952                    }
2953                    Err(error) => return Err(error),
2954                };
2955                let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2956                let bytes =
2957                    self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2958                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2959                manifest.version = selected.version.clone();
2960                manifest.channel = selected.channel;
2961                Ok((manifest, manifest_documents, vec![bytes.path]))
2962            }
2963            MatchLocator::PreIndexedV2 {
2964                package_rowid,
2965                package_hash,
2966            } => {
2967                let source = self.source_clone(located.source_index);
2968                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2969                let (entries, version_data_file) =
2970                    self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2971                let selected = match select_v2_version(
2972                    &entries,
2973                    query.version.as_deref(),
2974                    query.channel.as_deref(),
2975                    &located.display.id,
2976                    &source.name,
2977                ) {
2978                    Ok(selected) => selected,
2979                    Err(error)
2980                        if allow_explicit_version_refresh
2981                            && query.version.is_some()
2982                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2983                    {
2984                        let open_refresh = opened.refresh.clone();
2985                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2986                            return missing_error_after_open_refresh(error, &open_refresh);
2987                        }
2988                        if self.has_recent_successful_preindexed_refresh_attempt(
2989                            &self.preindexed_refresh_lock_key(&source),
2990                        ) {
2991                            return Err(error);
2992                        }
2993                        drop(opened);
2994                        return self.refresh_preindexed_and_retry_manifest(
2995                            located.source_index,
2996                            &located.display,
2997                            query,
2998                            error,
2999                        );
3000                    }
3001                    Err(error) => return Err(error),
3002                };
3003                let manifest_bytes = self.get_cached_source_file(
3004                    "V2_M",
3005                    &source,
3006                    &selected.manifest_relative_path,
3007                    Some(selected.manifest_hash.as_str()),
3008                )?;
3009                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
3010                manifest.version = selected.version;
3011                Ok((
3012                    manifest,
3013                    manifest_documents,
3014                    vec![version_data_file, manifest_bytes.path],
3015                ))
3016            }
3017            MatchLocator::Rest { package_id, versions } => {
3018                let source = self.source_clone(located.source_index);
3019                let selected = select_rest_version(
3020                    versions,
3021                    query.version.as_deref(),
3022                    query.channel.as_deref(),
3023                    &located.display.id,
3024                    &source.name,
3025                )?;
3026                let (bytes, cache_path) =
3027                    self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
3028                let (manifest, manifest_documents) =
3029                    parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
3030                Ok((manifest, manifest_documents, vec![cache_path]))
3031            }
3032        }
3033    }
3034
3035    fn refresh_preindexed_and_retry_manifest(
3036        &mut self,
3037        source_index: usize,
3038        previous_display: &SearchMatch,
3039        query: &PackageQuery,
3040        missing_error: anyhow::Error,
3041    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
3042        self.refresh_preindexed_source(source_index, true)
3043            .with_context(|| format!("{missing_error} The source refresh also failed"))?;
3044        let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
3045        self.manifest_for_match_with_refresh(&refreshed, query, false)
3046    }
3047
3048    fn find_refreshed_preindexed_match(
3049        &mut self,
3050        source_index: usize,
3051        previous_display: &SearchMatch,
3052        query: &PackageQuery,
3053    ) -> Result<LocatedMatch> {
3054        let source_name = self.store.sources[source_index].name.clone();
3055        let mut source_query = query.clone();
3056        source_query.source = Some(source_name.clone());
3057        let matches = self
3058            .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
3059            .matches;
3060        if let Some(found) = matches
3061            .iter()
3062            .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
3063        {
3064            return Ok(found.clone());
3065        }
3066        if matches.len() == 1 {
3067            return Ok(matches.into_iter().next().expect("single match"));
3068        }
3069        bail!(
3070            "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
3071            previous_display.id,
3072            source_name
3073        )
3074    }
3075
3076    fn search_preindexed(
3077        &mut self,
3078        source_index: usize,
3079        query: &PackageQuery,
3080        semantics: SearchSemantics,
3081    ) -> Result<SearchSourceMatches> {
3082        let connection = self.open_preindexed_connection(source_index)?;
3083        let source = self.source_clone(source_index);
3084
3085        match query_v2_matches(&connection, query, semantics) {
3086            Ok((rows, truncated)) => Ok(SearchSourceMatches {
3087                truncated,
3088                matches: rows
3089                    .into_iter()
3090                    .map(|row| LocatedMatch {
3091                        display: SearchMatch {
3092                            source_name: source.name.clone(),
3093                            source_kind: source.kind,
3094                            id: row.id.clone(),
3095                            name: row.name.clone(),
3096                            moniker: row.moniker.clone(),
3097                            version: Some(row.version.clone()),
3098                            channel: None,
3099                            match_criteria: row.match_criteria.clone(),
3100                        },
3101                        source_index,
3102                        locator: MatchLocator::PreIndexedV2 {
3103                            package_rowid: row.package_rowid,
3104                            package_hash: row.package_hash,
3105                        },
3106                    })
3107                    .collect(),
3108            }),
3109            Err(error) if can_fallback_to_v1(&error) => {
3110                let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
3111                let grouped = group_v1_rows(rows);
3112
3113                Ok(SearchSourceMatches {
3114                    truncated,
3115                    matches: grouped
3116                        .into_iter()
3117                        .map(|row| LocatedMatch {
3118                            display: SearchMatch {
3119                                source_name: source.name.clone(),
3120                                source_kind: source.kind,
3121                                id: row.id.clone(),
3122                                name: row.name.clone(),
3123                                moniker: row.moniker.clone(),
3124                                version: Some(row.version.clone()),
3125                                channel: Some(row.channel.clone()),
3126                                match_criteria: row.match_criteria.clone(),
3127                            },
3128                            source_index,
3129                            locator: MatchLocator::PreIndexedV1 {
3130                                package_rowid: row.package_rowid,
3131                            },
3132                        })
3133                        .collect(),
3134                })
3135            }
3136            Err(error) => Err(error),
3137        }
3138    }
3139
3140    fn search_rest(
3141        &mut self,
3142        source_index: usize,
3143        query: &PackageQuery,
3144        semantics: SearchSemantics,
3145    ) -> Result<SearchSourceMatches> {
3146        let source = self.source_clone(source_index);
3147        let info = self.load_rest_information(source_index)?;
3148        let contract = choose_contract(&info.server_supported_versions)
3149            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3150
3151        let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
3152        let body = build_rest_search_body(query, &info, semantics)?;
3153        let response = self
3154            .client
3155            .post(url)
3156            .header("Version", contract)
3157            .header(CONTENT_TYPE, "application/json")
3158            .body(serde_json::to_vec(&body).context("failed to serialize REST search request")?)
3159            .send()
3160            .context("REST search request failed")?
3161            .error_for_status()
3162            .context("REST search request returned an error")?;
3163        let json =
3164            serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST search response")?)
3165                .context("failed to parse REST search response")?;
3166        let data = json
3167            .get("Data")
3168            .and_then(JsonValue::as_array)
3169            .cloned()
3170            .unwrap_or_default();
3171        let max_results = source_fetch_results(query, semantics);
3172
3173        let mut results = Vec::new();
3174        for item in data {
3175            let package_id = json_string(&item, "PackageIdentifier")
3176                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
3177            let package_name =
3178                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
3179            let mut versions = parse_rest_versions(&item)?;
3180            sort_versions_desc(&mut versions);
3181            let latest = versions
3182                .first()
3183                .cloned()
3184                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
3185
3186            results.push(LocatedMatch {
3187                display: SearchMatch {
3188                    source_name: source.name.clone(),
3189                    source_kind: source.kind,
3190                    id: package_id.clone(),
3191                    name: package_name,
3192                    moniker: json_string(&item, "Moniker"),
3193                    version: Some(latest.version.clone()),
3194                    channel: if latest.channel.is_empty() {
3195                        None
3196                    } else {
3197                        Some(latest.channel.clone())
3198                    },
3199                    match_criteria: rest_match_criteria(&item, query, semantics),
3200                },
3201                source_index,
3202                locator: MatchLocator::Rest { package_id, versions },
3203            });
3204        }
3205
3206        Ok(SearchSourceMatches {
3207            truncated: results.len() >= max_results,
3208            matches: results,
3209        })
3210    }
3211
3212    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
3213        Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
3214    }
3215
3216    fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
3217        let source = self.source_clone(source_index);
3218        let index_path = self.preindexed_index_path(&source);
3219        let mut refresh = PreindexedRefreshOutcome::default();
3220        if !index_path.exists() {
3221            let _ = self.refresh_preindexed_source(source_index, true)?;
3222            refresh.attempted = true;
3223        } else if self.is_preindexed_index_stale(&source, &index_path) {
3224            let lock_key = self.preindexed_refresh_lock_key(&source);
3225            if self.has_recent_preindexed_refresh_attempt(&lock_key) {
3226                refresh.attempted = true;
3227                refresh.reused_recent_attempt = true;
3228            } else {
3229                refresh.attempted = true;
3230                if let Err(error) = self.refresh_preindexed_source(source_index, false) {
3231                    refresh.error = Some(format!("{error:#}"));
3232                }
3233            }
3234        }
3235
3236        Ok(PreindexedOpenConnection {
3237            connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
3238            refresh,
3239        })
3240    }
3241
3242    fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
3243        let Some(interval) = self.pre_indexed_source_auto_update_interval else {
3244            return false;
3245        };
3246
3247        let index_modified = || {
3248            fs::metadata(index_path)
3249                .ok()
3250                .and_then(|metadata| metadata.modified().ok())
3251                .map(DateTime::<Utc>::from)
3252        };
3253
3254        let last_update = match (source.last_update, index_modified()) {
3255            (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
3256            (Some(source_last_update), None) => Some(source_last_update),
3257            (None, Some(index_last_write)) => Some(index_last_write),
3258            (None, None) => None,
3259        };
3260
3261        match last_update {
3262            Some(last_update) => Utc::now() - last_update >= interval,
3263            None => true,
3264        }
3265    }
3266
3267    fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
3268        format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
3269    }
3270
3271    fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3272        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3273            Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3274        })
3275    }
3276
3277    fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3278        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3279            attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3280        })
3281    }
3282
3283    fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
3284        self.preindexed_refresh_attempts.insert(
3285            lock_key,
3286            PreindexedRefreshAttempt {
3287                attempted_at: Utc::now(),
3288                succeeded,
3289            },
3290        );
3291    }
3292
3293    fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
3294        {
3295            let locks = preindexed_refresh_locks()
3296                .read()
3297                .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3298            if let Some(existing) = locks.get(lock_key) {
3299                return Ok(Arc::clone(existing));
3300            }
3301        }
3302
3303        let mut locks = preindexed_refresh_locks()
3304            .write()
3305            .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3306        Ok(Arc::clone(
3307            locks
3308                .entry(lock_key.to_owned())
3309                .or_insert_with(|| Arc::new(Mutex::new(()))),
3310        ))
3311    }
3312
3313    fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
3314        let source = self.source_clone(source_index);
3315        let index_path = self.preindexed_index_path(&source);
3316        let lock_key = self.preindexed_refresh_lock_key(&source);
3317        let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
3318        let _guard = refresh_lock
3319            .lock()
3320            .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
3321
3322        if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
3323            return Ok("source already refreshed".to_owned());
3324        }
3325
3326        let result = self.update_preindexed(source_index);
3327        self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
3328        let detail = result?;
3329        self.save_store()?;
3330        Ok(detail)
3331    }
3332
3333    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
3334        let path_text = path.to_string_lossy().into_owned();
3335        let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
3336            .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
3337        database
3338            .connect()
3339            .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
3340    }
3341
3342    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
3343        let source_snapshot = self.store.sources[source_index].clone();
3344        let state_dir = self.source_state_dir(&source_snapshot);
3345        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
3346        let index_path = self.preindexed_index_path(&source_snapshot);
3347        let has_local_index = index_path.exists();
3348
3349        let mut last_error = None;
3350        for candidate in PREINDEXED_CANDIDATES {
3351            let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate);
3352            if has_local_index
3353                && let Some(detail) =
3354                    self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate)
3355            {
3356                return Ok(detail);
3357            }
3358
3359            let mut request = self.client.get(&url);
3360            if has_local_index {
3361                if let Some(etag) = &source_snapshot.etag
3362                    && let Ok(value) = HeaderValue::from_str(etag)
3363                {
3364                    request = request.header(IF_NONE_MATCH, value);
3365                }
3366                if let Some(last_modified) = &source_snapshot.last_modified
3367                    && let Ok(value) = HeaderValue::from_str(last_modified)
3368                {
3369                    request = request.header(IF_MODIFIED_SINCE, value);
3370                }
3371            }
3372
3373            match request.send() {
3374                Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => {
3375                    let source = &mut self.store.sources[source_index];
3376                    update_preindexed_http_validators(source, response.headers());
3377                    source.last_update = Some(Utc::now());
3378                    return Ok(format!("already up to date from {}", candidate));
3379                }
3380                Ok(response) if response.status().is_success() => {
3381                    let header_version = response
3382                        .headers()
3383                        .get("x-ms-meta-sourceversion")
3384                        .and_then(|value| value.to_str().ok())
3385                        .map(str::to_owned);
3386                    let headers = response.headers().clone();
3387                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
3388                    let payload = bytes.to_vec();
3389                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
3390                        .context("preindexed package did not contain Public/index.db")?;
3391
3392                    fs::write(self.preindexed_package_path(&source_snapshot), &payload)
3393                        .context("failed to persist source package")?;
3394                    let temp_index_path = state_dir.join(format!(
3395                        "index.{}.tmp",
3396                        Utc::now().timestamp_nanos_opt().unwrap_or_default()
3397                    ));
3398                    fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
3399                    if let Err(error) = replace_file(&temp_index_path, &index_path) {
3400                        let _ = fs::remove_file(&temp_index_path);
3401                        return Err(error).context("failed to persist source index");
3402                    }
3403                    // The new index.db is a fresh database; any WAL/SHM sidecars
3404                    // left from the previous one no longer match it and would make
3405                    // SQLite recover against the wrong database. Drop them so the
3406                    // next open starts clean.
3407                    remove_sqlite_sidecars(&index_path);
3408                    let source = &mut self.store.sources[source_index];
3409                    source.last_update = Some(Utc::now());
3410                    source.source_version = header_version;
3411                    update_preindexed_http_validators(source, &headers);
3412                    return Ok(format!("downloaded {}", candidate));
3413                }
3414                Ok(response) => {
3415                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3416                }
3417                Err(error) => {
3418                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3419                }
3420            }
3421        }
3422
3423        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3424    }
3425
3426    fn try_skip_unchanged_preindexed_by_source_version(
3427        &mut self,
3428        source_index: usize,
3429        url: &str,
3430        candidate: &str,
3431    ) -> Option<String> {
3432        let current_version = self.store.sources[source_index].source_version.as_deref()?;
3433        let response = self.client.head(url).send().ok()?;
3434        if !response.status().is_success() {
3435            return None;
3436        }
3437
3438        let header_version = response
3439            .headers()
3440            .get("x-ms-meta-sourceversion")
3441            .and_then(|value| value.to_str().ok())?;
3442        if !header_version.eq_ignore_ascii_case(current_version) {
3443            return None;
3444        }
3445
3446        let source = &mut self.store.sources[source_index];
3447        update_preindexed_http_validators(source, response.headers());
3448        source.last_update = Some(Utc::now());
3449        Some(format!("already up to date from {} (v{})", candidate, header_version))
3450    }
3451
3452    fn update_rest(&mut self, source_index: usize) -> Result<String> {
3453        let info = self.fetch_rest_information(source_index, true)?;
3454        let source = &mut self.store.sources[source_index];
3455        if !info.source_identifier.is_empty() {
3456            source.identifier = info.source_identifier.clone();
3457        }
3458        source.last_update = Some(Utc::now());
3459        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3460        Ok("refreshed information cache".to_owned())
3461    }
3462
3463    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3464        let source = self.source_clone(source_index);
3465        let cache_path = self.rest_information_cache_path(&source);
3466
3467        if cache_path.exists() {
3468            let cache = serde_json::from_slice::<RestInfoCache>(
3469                &fs::read(&cache_path).context("failed to read REST information cache")?,
3470            )
3471            .context("failed to parse REST information cache")?;
3472            if cache.expires_at > Utc::now() {
3473                return Ok(cache.value);
3474            }
3475        }
3476
3477        self.fetch_rest_information(source_index, true)
3478    }
3479
3480    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3481        let source = self.source_clone(source_index);
3482        let url = format!("{}/information", source.arg.trim_end_matches('/'));
3483        let response = self
3484            .client
3485            .get(url)
3486            .send()
3487            .context("REST information request failed")?
3488            .error_for_status()
3489            .context("REST information request returned an error")?;
3490
3491        let max_age = cache_control_max_age(&response);
3492        let json =
3493            serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST information response")?)
3494                .context("failed to parse REST information response")?;
3495        let data = json
3496            .get("Data")
3497            .cloned()
3498            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3499        let info = serde_json::from_value::<RestInformation>(data)
3500            .context("failed to deserialize REST information payload")?;
3501
3502        if persist {
3503            let cache = RestInfoCache {
3504                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3505                value: info.clone(),
3506            };
3507            write_json(self.rest_information_cache_path(&source), &cache)?;
3508        }
3509
3510        Ok(info)
3511    }
3512
3513    fn load_v2_version_data(
3514        &self,
3515        source: &SourceRecord,
3516        package_rowid: i64,
3517        package_hash: &str,
3518    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3519        let connection = Self::open_sqlite_connection(self.preindexed_index_path(source))
3520            .context("failed to reopen preindexed index for V2 version data")?;
3521        let package_hash = package_hash.to_ascii_lowercase();
3522        let package_id = query_optional_value(
3523            &connection,
3524            "SELECT id FROM packages WHERE rowid = ?1",
3525            vec![SqlValue::Integer(package_rowid)],
3526            |row| row_string(row, 0),
3527        )?
3528        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3529        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3530        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3531        let yaml = decompress_mszyml(&file.bytes)?;
3532        let document =
3533            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3534        Ok((document.versions, file.path))
3535    }
3536
3537    fn get_cached_source_file(
3538        &self,
3539        bucket: &str,
3540        source: &SourceRecord,
3541        relative_path: &str,
3542        expected_hash: Option<&str>,
3543    ) -> Result<CachedBytes> {
3544        let normalized_relative = relative_path.replace('\\', "/");
3545        let cache_path =
3546            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3547
3548        if cache_path.exists() {
3549            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3550            if hash_matches(expected_hash, &cached) {
3551                return Ok(CachedBytes {
3552                    path: cache_path,
3553                    bytes: cached,
3554                });
3555            }
3556        }
3557
3558        let url = format!(
3559            "{}/{}",
3560            source.arg.trim_end_matches('/'),
3561            normalized_relative.trim_start_matches('/')
3562        );
3563        let response = self
3564            .client
3565            .get(url)
3566            .send()
3567            .context("failed to fetch source file")?
3568            .error_for_status()
3569            .context("source file request returned an error")?;
3570        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3571
3572        if let Some(hash) = expected_hash {
3573            verify_hash(hash, &bytes)?;
3574        }
3575
3576        if let Some(parent) = cache_path.parent() {
3577            fs::create_dir_all(parent).context("failed to create cache directory")?;
3578        }
3579        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3580
3581        Ok(CachedBytes {
3582            path: cache_path,
3583            bytes,
3584        })
3585    }
3586
3587    fn get_or_fetch_rest_manifest(
3588        &mut self,
3589        source: &SourceRecord,
3590        package_id: &str,
3591        version: &str,
3592        channel: &str,
3593    ) -> Result<(Vec<u8>, PathBuf)> {
3594        let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel);
3595        if cache_path.exists() {
3596            return Ok((
3597                fs::read(&cache_path).context("failed to read cached REST manifest")?,
3598                cache_path,
3599            ));
3600        }
3601
3602        let info = self.load_rest_information_by_name(&source.name)?;
3603        let contract = choose_contract(&info.server_supported_versions)
3604            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3605        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3606
3607        let mut params = vec![("Version", version.to_owned())];
3608        if !channel.is_empty() {
3609            params.push(("Channel", channel.to_owned()));
3610        }
3611        if info
3612            .required_query_parameters
3613            .iter()
3614            .any(|value| value.eq_ignore_ascii_case("Market"))
3615        {
3616            params.push(("Market", default_market()));
3617        }
3618
3619        let response = self
3620            .client
3621            .get(url)
3622            .header("Version", contract)
3623            .query(&params)
3624            .send()
3625            .context("REST manifest request failed")?
3626            .error_for_status()
3627            .context("REST manifest request returned an error")?;
3628        let bytes = response
3629            .bytes()
3630            .context("failed to read REST manifest response")?
3631            .to_vec();
3632
3633        if let Some(parent) = cache_path.parent() {
3634            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3635        }
3636        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3637
3638        Ok((bytes, cache_path))
3639    }
3640
3641    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3642        let index = self
3643            .store
3644            .sources
3645            .iter()
3646            .position(|source| source.name.eq_ignore_ascii_case(name))
3647            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3648        self.load_rest_information(index)
3649    }
3650
3651    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3652        if let Some(name) = source_name {
3653            if let Some(index) = self
3654                .store
3655                .sources
3656                .iter()
3657                .position(|source| source.name.eq_ignore_ascii_case(name))
3658            {
3659                return Ok(vec![index]);
3660            }
3661
3662            let index = self
3663                .store
3664                .sources
3665                .iter()
3666                .position(|source| source.identifier.eq_ignore_ascii_case(name))
3667                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3668            return Ok(vec![index]);
3669        }
3670
3671        let mut indexes: Vec<_> = self
3672            .store
3673            .sources
3674            .iter()
3675            .enumerate()
3676            .filter(|(_, source)| !source.explicit)
3677            .collect();
3678        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3679            right_source
3680                .priority
3681                .cmp(&left_source.priority)
3682                .then_with(|| left_index.cmp(right_index))
3683        });
3684        Ok(indexes.into_iter().map(|(index, _)| index).collect())
3685    }
3686
3687    fn source_clone(&self, index: usize) -> SourceRecord {
3688        self.store.sources[index].clone()
3689    }
3690
3691    fn refresh_system_winget_sources(&mut self) -> Result<()> {
3692        self.store = match self.source_mode {
3693            EffectiveSourceMode::Private => self.store.clone(),
3694            EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?,
3695            EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?,
3696        };
3697        Ok(())
3698    }
3699
3700    /// A query must not fail because the sources could not be re-mirrored: the cached
3701    /// mirror is what the previous queries already ran against. Mutating commands keep
3702    /// using the strict refresh above, since they have to see the real source list.
3703    fn refresh_system_winget_sources_for_query(&mut self) -> Option<String> {
3704        self.refresh_system_winget_sources_for_query_with_max_age(StdDuration::from_secs(
3705            SYSTEM_WINGET_MIRROR_AUTO_REFRESH_MINUTES * 60,
3706        ))
3707    }
3708
3709    fn refresh_system_winget_sources_for_query_with_max_age(&mut self, max_age: StdDuration) -> Option<String> {
3710        match self.source_mode {
3711            EffectiveSourceMode::Private => None,
3712            EffectiveSourceMode::SystemWingetDirect => match load_system_winget_source_store() {
3713                Ok(store) => {
3714                    self.store = store;
3715                    None
3716                }
3717                Err(error) => Some(system_winget_source_refresh_warning(&error)),
3718            },
3719            EffectiveSourceMode::SystemWingetMirror => {
3720                if system_winget_mirror_is_fresh(&self.app_root, max_age) {
3721                    return None;
3722                }
3723
3724                match refresh_system_winget_mirror_store(&self.app_root) {
3725                    Ok(store) => {
3726                        self.store = store;
3727                        None
3728                    }
3729                    Err(error) => Some(system_winget_source_refresh_warning(&error)),
3730                }
3731            }
3732        }
3733    }
3734
3735    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3736        self.refresh_system_winget_sources()?;
3737        let selected_names: Vec<_> = if source_name.is_none() {
3738            self.store.sources.iter().map(|source| source.name.clone()).collect()
3739        } else {
3740            self.resolve_source_indexes(source_name)?
3741                .into_iter()
3742                .map(|index| self.store.sources[index].name.clone())
3743                .collect()
3744        };
3745
3746        let detail = system_winget_update_sources(source_name)?;
3747        self.refresh_system_winget_sources()?;
3748
3749        Ok(selected_names
3750            .into_iter()
3751            .filter_map(|name| {
3752                self.store
3753                    .sources
3754                    .iter()
3755                    .find(|source| source.name.eq_ignore_ascii_case(&name))
3756                    .map(|source| SourceUpdateResult {
3757                        name: source.name.clone(),
3758                        kind: source.kind,
3759                        detail: detail.clone(),
3760                    })
3761            })
3762            .collect())
3763    }
3764}
3765
3766#[derive(Debug)]
3767struct CachedBytes {
3768    path: PathBuf,
3769    bytes: Vec<u8>,
3770}
3771
3772#[derive(Debug, Clone)]
3773struct V1SearchRow {
3774    manifest_rowid: i64,
3775    package_rowid: i64,
3776    version: String,
3777    channel: String,
3778    id: String,
3779    name: String,
3780    moniker: Option<String>,
3781    match_criteria: Option<String>,
3782}
3783
3784#[derive(Debug, Clone)]
3785struct V1VersionRow {
3786    version: String,
3787    channel: String,
3788    pathpart_id: i64,
3789    manifest_hash: Option<String>,
3790}
3791
3792#[derive(Debug, Clone)]
3793struct V2SearchRow {
3794    package_rowid: i64,
3795    package_hash: String,
3796    id: String,
3797    name: String,
3798    moniker: Option<String>,
3799    version: String,
3800    match_criteria: Option<String>,
3801}
3802
3803fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3804    query.query.is_some()
3805        || query.id.is_some()
3806        || query.name.is_some()
3807        || query.moniker.is_some()
3808        || query.tag.is_some()
3809        || query.command.is_some()
3810        || query.source.is_some()
3811}
3812
3813fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3814    PackageQuery {
3815        query: query.query.clone(),
3816        id: query.id.clone(),
3817        name: query.name.clone(),
3818        moniker: query.moniker.clone(),
3819        tag: query.tag.clone(),
3820        command: query.command.clone(),
3821        source: query.source.clone(),
3822        count: Some(LIST_LOOKUP_MAX_RESULTS),
3823        exact: query.exact,
3824        version: None,
3825        channel: None,
3826        locale: None,
3827        installer_type: None,
3828        installer_architecture: None,
3829        platform: None,
3830        os_version: None,
3831        install_scope: query.install_scope.clone(),
3832    }
3833}
3834
3835fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3836    query.query.is_some() || query.id.is_some() || query.name.is_some()
3837}
3838
3839fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3840    if is_greater_than_version_marker(&package.installed_version) {
3841        return false;
3842    }
3843    package
3844        .correlated
3845        .as_ref()
3846        .and_then(|candidate| candidate.version.as_deref())
3847        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3848}
3849
3850fn is_greater_than_version_marker(version: &str) -> bool {
3851    version.trim_start().starts_with('>')
3852}
3853
3854fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3855    package.installed_version.eq_ignore_ascii_case("Unknown")
3856}
3857
3858fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3859    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
3860    // Discord). Explicit `--id` filters surface them regardless.
3861    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3862        return false;
3863    }
3864    // Hide rows whose latest catalog version has no installer the host can
3865    // run — winget refuses to upgrade past an unusable architecture.
3866    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3867        return false;
3868    }
3869    installed_package_has_upgrade(package)
3870        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3871}
3872
3873/// Collapses installed entries that correlate to the same catalog package id
3874/// (case-insensitive within a source) down to a single representative — the
3875/// one with the highest installed version. Uncorrelated entries pass through
3876/// untouched; they can't appear in `upgrade` output anyway (the upgrade
3877/// filter requires a correlation), so they're effectively a no-op here.
3878fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3879    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3880    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3881
3882    for package in packages {
3883        match package.correlated.as_ref() {
3884            Some(candidate) => {
3885                let key = (
3886                    candidate.id.to_ascii_lowercase(),
3887                    candidate.source_name.to_ascii_lowercase(),
3888                );
3889                let keep_new = match by_id.get(&key) {
3890                    Some(existing) => {
3891                        // Prefer rows whose `installed_version` was remapped to
3892                        // a catalog Version (via aMiV/aMaV). Comparing a raw
3893                        // ARP DisplayVersion like `40.10.18029` against the
3894                        // catalog's `10.0.300` is meaningless and would also
3895                        // hide the row that legitimately upgrades to it.
3896                        match (
3897                            package.installed_version_canonical,
3898                            existing.installed_version_canonical,
3899                        ) {
3900                            (true, false) => true,
3901                            (false, true) => false,
3902                            _ => {
3903                                compare_version(&package.installed_version, &existing.installed_version)
3904                                    == Ordering::Greater
3905                            }
3906                        }
3907                    }
3908                    None => true,
3909                };
3910                if keep_new {
3911                    by_id.insert(key, package);
3912                }
3913            }
3914            None => uncorrelated.push(package),
3915        }
3916    }
3917
3918    let mut result = uncorrelated;
3919    result.extend(by_id.into_values());
3920    result
3921}
3922
3923fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3924    let mut source_specific = None;
3925    let mut source_agnostic = None;
3926
3927    for pin in pins {
3928        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3929            continue;
3930        }
3931
3932        if pin.source_id.is_empty() {
3933            if source_agnostic.is_none() {
3934                source_agnostic = Some(pin);
3935            }
3936        } else if item
3937            .source_name
3938            .as_deref()
3939            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3940        {
3941            source_specific = Some(pin);
3942            break;
3943        }
3944    }
3945
3946    source_specific.or(source_agnostic)
3947}
3948
3949fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3950    let Some(available_version) = item.available_version.as_deref() else {
3951        return false;
3952    };
3953    let Some(pin) = find_applicable_pin(item, pins) else {
3954        return false;
3955    };
3956
3957    match pin.pin_type {
3958        PinType::Blocking => true,
3959        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3960    }
3961}
3962
3963fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3964    if pattern.trim().is_empty() || pattern == "*" {
3965        return true;
3966    }
3967
3968    if !pattern.contains('*') {
3969        return version.eq_ignore_ascii_case(pattern);
3970    }
3971
3972    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3973        let prefix = &pattern[..pattern.len() - 1];
3974        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3975    }
3976
3977    version.eq_ignore_ascii_case(pattern)
3978}
3979
3980fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch {
3981    let available_version = package.correlated.as_ref().and_then(|candidate| {
3982        candidate.version.as_ref().and_then(|candidate_version| {
3983            if !is_greater_than_version_marker(&package.installed_version)
3984                && (installed_package_has_unknown_version(&package)
3985                    || compare_version(candidate_version, &package.installed_version) == Ordering::Greater)
3986            {
3987                Some(candidate_version.clone())
3988            } else {
3989                None
3990            }
3991        })
3992    });
3993    let mut source_name = package
3994        .correlated
3995        .as_ref()
3996        .map(|candidate| candidate.source_name.clone())
3997        .filter(|value| !value.is_empty());
3998    let mut id = package
3999        .correlated
4000        .as_ref()
4001        .map(|candidate| candidate.id.clone())
4002        .unwrap_or_else(|| package.local_id.clone());
4003
4004    if source_name.is_none()
4005        && let Some(package_id) = package
4006            .winget_package_identifier
4007            .as_deref()
4008            .filter(|value| !value.trim().is_empty())
4009    {
4010        id = package_id.to_owned();
4011        source_name = package
4012            .winget_source_identifier
4013            .as_deref()
4014            .filter(|value| !value.trim().is_empty())
4015            .map(|identifier| source_name_from_identifier(identifier, sources));
4016    }
4017
4018    if source_name.is_none()
4019        && let Some((local_package_id, local_source_name)) =
4020            winget_package_identity_from_local_id(&package.local_id, sources)
4021    {
4022        id = local_package_id;
4023        source_name = Some(local_source_name);
4024    }
4025
4026    ListMatch {
4027        name: package.name,
4028        id,
4029        local_id: package.local_id,
4030        installed_version: package.installed_version,
4031        available_version,
4032        source_name,
4033        publisher: package.publisher,
4034        scope: package.scope,
4035        installer_category: package.installer_category,
4036        install_location: package.install_location,
4037        package_family_names: package.package_family_names,
4038        product_codes: package.product_codes,
4039        upgrade_codes: package.upgrade_codes,
4040    }
4041}
4042
4043fn suppress_duplicate_available_versions(matches: Vec<ListMatch>) -> Vec<ListMatch> {
4044    let mut grouped: HashMap<(String, String), Vec<usize>> = HashMap::new();
4045    for (index, item) in matches.iter().enumerate() {
4046        let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else {
4047            continue;
4048        };
4049        grouped
4050            .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4051            .or_default()
4052            .push(index);
4053    }
4054
4055    let mut keep = BTreeSet::new();
4056    for indexes in grouped.values().filter(|indexes| indexes.len() > 1) {
4057        let Some(best) = indexes
4058            .iter()
4059            .map(|index| matches[*index].installed_version.as_str())
4060            .max_by(|left, right| compare_version(left, right))
4061        else {
4062            continue;
4063        };
4064        for index in indexes {
4065            if compare_version(&matches[*index].installed_version, best) == Ordering::Equal {
4066                keep.insert(*index);
4067            }
4068        }
4069    }
4070
4071    matches
4072        .into_iter()
4073        .enumerate()
4074        .map(|(index, mut item)| {
4075            if item.available_version.is_some() && !keep.contains(&index) {
4076                let is_duplicate = item
4077                    .source_name
4078                    .as_ref()
4079                    .map(|source_name| {
4080                        grouped
4081                            .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4082                            .is_some_and(|indexes| indexes.len() > 1)
4083                    })
4084                    .unwrap_or(false);
4085                if is_duplicate {
4086                    item.available_version = None;
4087                }
4088            }
4089            item
4090        })
4091        .collect()
4092}
4093
4094fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String {
4095    sources
4096        .iter()
4097        .find(|source| source.identifier.eq_ignore_ascii_case(identifier))
4098        .map(|source| source.name.clone())
4099        .unwrap_or_else(|| identifier.to_owned())
4100}
4101
4102fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> {
4103    let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1);
4104    for source in sources {
4105        let source_suffix = format!("_{}", source.identifier);
4106        let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else {
4107            continue;
4108        };
4109        if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) {
4110            continue;
4111        }
4112        if source_identifier_start_index <= package_id_start_index {
4113            return None;
4114        }
4115        let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned();
4116        if package_id.trim().is_empty() {
4117            return None;
4118        }
4119        return Some((package_id, source.name.clone()));
4120    }
4121    None
4122}
4123
4124fn list_sort_weight(package: &InstalledPackage) -> usize {
4125    if package.local_id.starts_with("ARP\\") {
4126        0
4127    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
4128        1
4129    } else {
4130        2
4131    }
4132}
4133
4134fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
4135    let correlated = package.correlated.as_ref();
4136
4137    if let Some(value) = &query.id {
4138        let local_match = matches_text(&package.local_id, value, query.exact);
4139        let correlated_match = correlated
4140            .map(|candidate| matches_text(&candidate.id, value, query.exact))
4141            .unwrap_or(false);
4142        if !local_match && !correlated_match {
4143            return false;
4144        }
4145    }
4146
4147    if let Some(value) = &query.name
4148        && !matches_text(&package.name, value, query.exact)
4149    {
4150        return false;
4151    }
4152
4153    if let Some(value) = &query.product_code
4154        && !package
4155            .product_codes
4156            .iter()
4157            .any(|code| code.eq_ignore_ascii_case(value))
4158    {
4159        return false;
4160    }
4161
4162    if let Some(value) = &query.version
4163        && !package.installed_version.eq_ignore_ascii_case(value)
4164    {
4165        return false;
4166    }
4167
4168    if let Some(value) = &query.query {
4169        let local_match =
4170            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
4171        let correlated_match = correlated
4172            .map(|candidate| {
4173                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
4174            })
4175            .unwrap_or(false);
4176        if !local_match && !correlated_match {
4177            return false;
4178        }
4179    }
4180
4181    if let Some(source) = &query.source
4182        && correlated
4183            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
4184            .unwrap_or(true)
4185    {
4186        return false;
4187    }
4188
4189    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
4190        return false;
4191    }
4192
4193    true
4194}
4195
4196fn correlate_installed_package(
4197    package: &InstalledPackage,
4198    candidates: &[SearchMatch],
4199    allow_loose_name_match: bool,
4200) -> Option<SearchMatch> {
4201    // MSIX entries can only be correlated through their PackageFamilyName.
4202    // That lookup happens earlier in `correlate_installed_via_index`; if it
4203    // didn't find a match, name-based fallback is wrong — the catalog
4204    // doesn't carry this MSIX's identity, so any name collision (e.g. the
4205    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
4206    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
4207    // doesn't make.
4208    if package.local_id.starts_with("MSIX\\") {
4209        return None;
4210    }
4211
4212    let installed_name = normalize_correlation_name(&package.name);
4213    let installed_name_lower = package.name.to_ascii_lowercase();
4214    let candidate_names = correlation_name_candidates(&package.name);
4215
4216    let mut scored: Vec<(i32, SearchMatch)> = candidates
4217        .iter()
4218        .filter_map(|candidate| {
4219            let candidate_name = normalize_correlation_name(&candidate.name);
4220            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
4221                1000
4222            } else if candidate_names.iter().any(|name| {
4223                let normalized = normalize_correlation_name(name);
4224                normalized == candidate_name
4225            }) {
4226                900
4227            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
4228                700
4229            } else {
4230                0
4231            };
4232
4233            if base_score == 0 {
4234                return None;
4235            }
4236
4237            // Tiebreaker so we pick the *right* candidate when multiple catalog
4238            // entries collapse to the same normalized name or all loose-match a
4239            // long installed name. Two failure modes this addresses:
4240            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
4241            //     alphanumeric filter strips `+`/`-`), so both score 900 and
4242            //     `max_by_key` arbitrarily picked the latter.
4243            //   • Loose substring match picks up `Studio` inside
4244            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
4245            //     `Microsoft.DotNet.SDK.10` when both score 700.
4246            // The bonus rewards catalog names that appear verbatim in the
4247            // installed display name — strongly when they anchor the start,
4248            // weaker when they're embedded.
4249            let candidate_lower = candidate.name.to_ascii_lowercase();
4250            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
4251                100
4252            } else if installed_name_lower.contains(&candidate_lower) {
4253                50
4254            } else {
4255                0
4256            };
4257
4258            Some((base_score + prefix_bonus, candidate.clone()))
4259        })
4260        .collect();
4261
4262    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
4263    let mut iter = scored.into_iter();
4264    let (top_score, top_match) = iter.next()?;
4265
4266    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
4267    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
4268    // normalize their names to `git` and tie at the same score — the only
4269    // signal that disambiguates them, publisher, isn't part of this scoring
4270    // function. winget refuses the correlation in this case (the install
4271    // shows up with an empty Source); we do the same to avoid manufacturing
4272    // a fake upgrade against the wrong package.
4273    if let Some((next_score, _)) = iter.next()
4274        && next_score == top_score
4275    {
4276        return None;
4277    }
4278    Some(top_match)
4279}
4280
4281fn correlation_name_candidates(name: &str) -> Vec<String> {
4282    let mut candidates = Vec::new();
4283    candidates.push(name.trim().to_owned());
4284
4285    let mut trimmed = name.trim().to_owned();
4286    if let Some(index) = trimmed.find(" (") {
4287        trimmed.truncate(index);
4288    }
4289
4290    let mut words = Vec::new();
4291    for token in trimmed.split_whitespace() {
4292        let lower = token
4293            .trim_matches(|ch: char| ch == '(' || ch == ')')
4294            .to_ascii_lowercase();
4295        if !words.is_empty()
4296            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
4297        {
4298            break;
4299        }
4300        words.push(token);
4301    }
4302
4303    if !words.is_empty() {
4304        candidates.push(words.join(" "));
4305    }
4306
4307    candidates.sort();
4308    candidates.dedup();
4309    candidates
4310}
4311
4312fn normalize_correlation_name(value: &str) -> String {
4313    value
4314        .chars()
4315        .filter(|ch| ch.is_ascii_alphanumeric())
4316        .flat_map(|ch| ch.to_lowercase())
4317        .collect()
4318}
4319
4320struct V2PackageMetadata {
4321    rowid: i64,
4322    id: String,
4323    name: String,
4324    moniker: Option<String>,
4325    latest_version: String,
4326    package_hash: String,
4327}
4328
4329/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
4330/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
4331/// and the field that produced the match. This is winget's primary correlation
4332/// path; falling back to display-name matching is only correct for ARP entries
4333/// that lack all three identifiers.
4334fn lookup_identity_match_v2(
4335    connection: &Connection,
4336    package: &InstalledPackage,
4337) -> Result<Option<(i64, &'static str)>> {
4338    for pfn in &package.package_family_names {
4339        if let Some(rowid) = query_optional_value(
4340            connection,
4341            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
4342            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
4343            |row| row_i64(row, 0),
4344        )? {
4345            return Ok(Some((rowid, "PackageFamilyName")));
4346        }
4347    }
4348    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
4349    // identity — it stays stable across versions, while ProductCode changes
4350    // every release. When a single ProductCode happens to also be listed
4351    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
4352    // installer rows from earlier LTS-line ProductCodes, while the v24
4353    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
4354    // the upgrade-side match represents the user's "package family" — the
4355    // one winget uses.
4356    for code in &package.upgrade_codes {
4357        if let Some(rowid) = query_optional_value(
4358            connection,
4359            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
4360            vec![SqlValue::Text(code.to_ascii_lowercase())],
4361            |row| row_i64(row, 0),
4362        )? {
4363            return Ok(Some((rowid, "UpgradeCode")));
4364        }
4365    }
4366    for code in &package.product_codes {
4367        if let Some(rowid) = query_optional_value(
4368            connection,
4369            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
4370            vec![SqlValue::Text(code.to_ascii_lowercase())],
4371            |row| row_i64(row, 0),
4372        )? {
4373            return Ok(Some((rowid, "ProductCode")));
4374        }
4375    }
4376    Ok(None)
4377}
4378
4379fn installed_db_identity_tables_present(connection: &Connection) -> Result<bool> {
4380    Ok(table_exists(connection, "manifest")?
4381        && table_exists(connection, "ids")?
4382        && table_exists(connection, "names")?
4383        && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?)
4384            || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?)
4385            || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?)))
4386}
4387
4388fn table_exists(connection: &Connection, name: &str) -> Result<bool> {
4389    query_optional_value(
4390        connection,
4391        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
4392        vec![SqlValue::Text(name.to_owned())],
4393        |row| row_i64(row, 0),
4394    )
4395    .map(|value| value.is_some())
4396}
4397
4398fn lookup_installed_db_identity_match(
4399    connection: &Connection,
4400    package: &InstalledPackage,
4401) -> Result<Option<(String, String, &'static str)>> {
4402    for pfn in &package.package_family_names {
4403        if let Some(hit) =
4404            lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")?
4405        {
4406            return Ok(Some(hit));
4407        }
4408    }
4409    for code in &package.upgrade_codes {
4410        if let Some(hit) = lookup_installed_db_identity_match_value(
4411            connection,
4412            "upgradecodes",
4413            "upgradecodes_map",
4414            "upgradecode",
4415            code,
4416            "UpgradeCode",
4417        )? {
4418            return Ok(Some(hit));
4419        }
4420    }
4421    for code in &package.product_codes {
4422        if let Some(hit) = lookup_installed_db_identity_match_value(
4423            connection,
4424            "productcodes",
4425            "productcodes_map",
4426            "productcode",
4427            code,
4428            "ProductCode",
4429        )? {
4430            return Ok(Some(hit));
4431        }
4432    }
4433    Ok(None)
4434}
4435
4436fn lookup_installed_db_identity_match_value(
4437    connection: &Connection,
4438    value_table: &'static str,
4439    map_table: &'static str,
4440    value_column: &'static str,
4441    value: &str,
4442    matched_by: &'static str,
4443) -> Result<Option<(String, String, &'static str)>> {
4444    let sql = format!(
4445        "SELECT ids.id, names.name \
4446         FROM {map_table} map \
4447         JOIN {value_table} value ON map.{value_column} = value.rowid \
4448         JOIN manifest ON map.manifest = manifest.rowid \
4449         JOIN ids ON manifest.id = ids.rowid \
4450         JOIN names ON manifest.name = names.rowid \
4451         WHERE value.{value_column} = ?1 \
4452         LIMIT 1"
4453    );
4454    query_optional_value(
4455        connection,
4456        &sql,
4457        vec![SqlValue::Text(value.to_ascii_lowercase())],
4458        |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)),
4459    )
4460}
4461
4462/// Resolves a v2 package by its catalog id back to its rowid + hash so the
4463/// post-correlation enrichment pass can load versionData for rows that
4464/// correlated through the name-based fallback (which throws the rowid away).
4465fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
4466    query_optional_value(
4467        connection,
4468        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
4469        vec![SqlValue::Text(package_id.to_owned())],
4470        |row| {
4471            Ok(V2PackageMetadata {
4472                rowid: row_i64(row, 0)?,
4473                id: row_string(row, 1)?,
4474                name: row_string(row, 2)?,
4475                moniker: row_opt_string(row, 3)?,
4476                latest_version: row_string(row, 4)?,
4477                package_hash: row_hex_string(row, 5)?,
4478            })
4479        },
4480    )
4481}
4482
4483/// MSIX entries whose Windows resource-string display name (e.g.
4484/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
4485/// would otherwise leak that placeholder into pinget's output. winget
4486/// resolves it via `Windows.Management.Deployment` and shows the catalog's
4487/// `PackageName`; we mirror that by substituting the correlated catalog
4488/// name once the package is correlated, which is the parity-faithful
4489/// substitute we can produce without taking a WinRT dependency.
4490fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
4491    if !package.local_id.starts_with("MSIX\\") {
4492        return;
4493    }
4494    if !package.name.starts_with("ms-resource:") {
4495        return;
4496    }
4497    let Some(candidate) = package.correlated.as_ref() else {
4498        return;
4499    };
4500    if !candidate.name.is_empty() {
4501        package.name = candidate.name.clone();
4502    }
4503}
4504
4505fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
4506    query_optional_value(
4507        connection,
4508        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
4509        vec![SqlValue::Integer(package_rowid)],
4510        |row| {
4511            Ok(V2PackageMetadata {
4512                rowid: package_rowid,
4513                id: row_string(row, 0)?,
4514                name: row_string(row, 1)?,
4515                moniker: row_opt_string(row, 2)?,
4516                latest_version: row_string(row, 3)?,
4517                package_hash: row_hex_string(row, 4)?,
4518            })
4519        },
4520    )
4521}
4522
4523fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
4524    query_optional_value(
4525        connection,
4526        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
4527        Vec::new(),
4528        |row| row_i64(row, 0),
4529    )
4530    .ok()
4531    .flatten()
4532    .is_some()
4533}
4534
4535/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
4536/// pair matches the installed package — if and only if the match is
4537/// unique. Multiple matches mean the normalization wasn't strong enough to
4538/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
4539/// distinct catalog packages); winget refuses to correlate in that case
4540/// and we do the same.
4541fn lookup_unique_normalized_identity(
4542    connection: &Connection,
4543    norm_name: &str,
4544    norm_publisher: &str,
4545) -> Result<Option<i64>> {
4546    let rows: Vec<i64> = query_rows(
4547        connection,
4548        "SELECT DISTINCT n.package FROM norm_names2 n \
4549         WHERE n.norm_name = ?1 \
4550         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
4551         LIMIT 2",
4552        vec![
4553            SqlValue::Text(norm_name.to_owned()),
4554            SqlValue::Text(norm_publisher.to_owned()),
4555        ],
4556        |row| row_i64(row, 0),
4557    )?;
4558    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
4559}
4560
4561fn v2_identity_tables_present(connection: &Connection) -> bool {
4562    query_optional_value(
4563        connection,
4564        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
4565        Vec::new(),
4566        |row| row_i64(row, 0),
4567    )
4568    .ok()
4569    .flatten()
4570    .is_some()
4571}
4572
4573/// Translates an ARP DisplayVersion to the catalog Version it maps to.
4574/// For packages like the .NET SDK or Python the ARP version is an MSI build
4575/// number (e.g. `10.1.826.23019`) unrelated to the public version
4576/// (`10.0.108`). winget records the ARP range per catalog version in
4577/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
4578/// contains the installed version.
4579fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4580    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4581        return None;
4582    }
4583    for entry in entries {
4584        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
4585            continue;
4586        };
4587        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
4588        {
4589            return Some(entry.version.clone());
4590        }
4591    }
4592    None
4593}
4594
4595/// Returns `< latest>` when ARP range metadata proves the installed version is
4596/// below the newest catalog version, but the raw DisplayVersion is too coarse
4597/// to map into a concrete aMiV/aMaV bucket. Snagit 2025 reports `25.4.1` while
4598/// the catalog's latest ARP build is `25.4.1.10325`; winget renders that as
4599/// installed `< 25.4.1` with available `25.4.1`.
4600fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4601    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4602        return None;
4603    }
4604
4605    let latest = entries
4606        .iter()
4607        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4608        .max_by(|a, b| compare_version(&a.version, &b.version))?;
4609    if compare_version(arp_version, &latest.version) != Ordering::Equal {
4610        return None;
4611    }
4612    let latest_min = latest.arp_min_version.as_deref()?;
4613    if compare_version(arp_version, latest_min) != Ordering::Less {
4614        return None;
4615    }
4616
4617    let previous_max = entries
4618        .iter()
4619        .filter(|entry| compare_version(&entry.version, &latest.version) == Ordering::Less)
4620        .filter_map(|entry| entry.arp_max_version.as_deref())
4621        .max_by(|left, right| compare_version(left, right));
4622    if previous_max.is_some_and(|max| compare_version(arp_version, max) != Ordering::Greater) {
4623        return None;
4624    }
4625
4626    Some(format!("< {}", latest.version))
4627}
4628
4629/// Returns `> latest` when ARP range metadata proves the installed package
4630/// version is newer than the newest catalog version that has ARP bounds. This
4631/// happens for inbox MSIX frameworks that Windows services beyond winget's
4632/// latest user-facing catalog mapping.
4633fn greater_than_latest_arp_anchored_version(
4634    entries: &[PackageVersionDataEntry],
4635    arp_version: &str,
4636    catalog_latest_version: &str,
4637) -> Option<String> {
4638    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4639        return None;
4640    }
4641
4642    let latest = entries
4643        .iter()
4644        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4645        .max_by(|a, b| compare_version(&a.version, &b.version))?;
4646    let latest_max = latest.arp_max_version.as_deref()?;
4647    if !latest.version.eq_ignore_ascii_case(catalog_latest_version) {
4648        return None;
4649    }
4650    if !has_different_leading_version_number(latest_max, &latest.version) {
4651        return None;
4652    }
4653    if compare_version(arp_version, latest_max) != Ordering::Greater {
4654        return None;
4655    }
4656
4657    Some(format!("> {}", latest.version))
4658}
4659
4660fn has_different_leading_version_number(left: &str, right: &str) -> bool {
4661    let Some(left) = leading_version_number(left) else {
4662        return false;
4663    };
4664    let Some(right) = leading_version_number(right) else {
4665        return false;
4666    };
4667    !left.eq_ignore_ascii_case(right)
4668}
4669
4670fn leading_version_number(value: &str) -> Option<&str> {
4671    let trimmed = value.trim_start();
4672    let length = trimmed
4673        .chars()
4674        .take_while(|ch| ch.is_ascii_digit())
4675        .map(char::len_utf8)
4676        .sum();
4677    (length > 0).then_some(&trimmed[..length])
4678}
4679
4680/// Returns the latest catalog Version that carries ARP-range metadata
4681/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
4682/// publish "internal" version rows whose `v` is an MSI build number
4683/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
4684/// upgrade targets — when the installed side was matched through an ARP
4685/// range, the available side must be a peer that also exposes ARP bounds so
4686/// the two versions compare on the same scale. Returns `None` when no entry
4687/// has bounds; the caller should fall back to `packages.latest_version`.
4688fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
4689    entries
4690        .iter()
4691        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4692        .max_by(|a, b| compare_version(&a.version, &b.version))
4693        .map(|entry| entry.version.clone())
4694}
4695
4696#[cfg(windows)]
4697fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4698    let mut packages = Vec::new();
4699    let mut seen = BTreeSet::new();
4700
4701    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
4702    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
4703
4704    // MSI registers UpgradeCode → ProductCode mappings under
4705    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
4706    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
4707    // entries don't expose `UpgradeCode` directly, so winget reads it from
4708    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
4709    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
4710    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
4711    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
4712
4713    if machine {
4714        collect_uninstall_view(
4715            &mut packages,
4716            &mut seen,
4717            &upgrade_codes,
4718            RegKey::predef(HKEY_LOCAL_MACHINE),
4719            "Machine",
4720            "X64",
4721            KEY_READ | KEY_WOW64_64KEY,
4722        )?;
4723        collect_uninstall_view(
4724            &mut packages,
4725            &mut seen,
4726            &upgrade_codes,
4727            RegKey::predef(HKEY_LOCAL_MACHINE),
4728            "Machine",
4729            "X86",
4730            KEY_READ | KEY_WOW64_32KEY,
4731        )?;
4732        collect_appmodel_packages(
4733            &mut packages,
4734            &mut seen,
4735            RegKey::predef(HKEY_LOCAL_MACHINE),
4736            "Machine",
4737            KEY_READ | KEY_WOW64_64KEY,
4738        )?;
4739    }
4740
4741    if user {
4742        collect_uninstall_view(
4743            &mut packages,
4744            &mut seen,
4745            &upgrade_codes,
4746            RegKey::predef(HKEY_CURRENT_USER),
4747            "User",
4748            "X64",
4749            KEY_READ,
4750        )?;
4751        collect_appmodel_packages(
4752            &mut packages,
4753            &mut seen,
4754            RegKey::predef(HKEY_CURRENT_USER),
4755            "User",
4756            KEY_READ,
4757        )?;
4758    }
4759
4760    Ok(packages)
4761}
4762
4763/// Builds a `ProductCode → UpgradeCode` map (both in standard
4764/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
4765/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
4766/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
4767/// flipped ProductCodes registered under that UpgradeCode.
4768#[cfg(windows)]
4769fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4770    let mut map = HashMap::new();
4771    if include_machine {
4772        collect_msi_upgrade_codes_from(
4773            RegKey::predef(HKEY_LOCAL_MACHINE),
4774            r"SOFTWARE\Classes\Installer\UpgradeCodes",
4775            KEY_READ | KEY_WOW64_64KEY,
4776            &mut map,
4777        );
4778    }
4779    if include_user {
4780        collect_msi_upgrade_codes_from(
4781            RegKey::predef(HKEY_CURRENT_USER),
4782            r"Software\Microsoft\Installer\UpgradeCodes",
4783            KEY_READ,
4784            &mut map,
4785        );
4786    }
4787    map
4788}
4789
4790#[cfg(windows)]
4791fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4792    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4793        return;
4794    };
4795    for key_name in upgrade_codes.enum_keys().flatten() {
4796        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4797            continue;
4798        };
4799        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4800            continue;
4801        };
4802        for (value_name, _) in subkey.enum_values().flatten() {
4803            if let Some(product_code) = unflip_packed_guid(&value_name) {
4804                // ARP-side keys are likely to win the latest insert; first one
4805                // is fine — we just need *any* mapping. Use `entry` so the
4806                // first writer per ProductCode wins to stay deterministic.
4807                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4808            }
4809        }
4810    }
4811}
4812
4813/// Converts the 32-character "packed GUID" used inside the MSI Installer
4814/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
4815/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
4816/// then eight 2-char byte pairs) of the GUID's hex representation.
4817#[cfg(windows)]
4818fn unflip_packed_guid(packed: &str) -> Option<String> {
4819    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4820        return None;
4821    }
4822    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4823    let bytes = packed.as_bytes();
4824    let mut reversed = String::with_capacity(32);
4825    let mut offset = 0;
4826    for size in CHUNKS {
4827        let chunk = &bytes[offset..offset + size];
4828        for &c in chunk.iter().rev() {
4829            reversed.push(c as char);
4830        }
4831        offset += size;
4832    }
4833    let r = reversed.to_ascii_lowercase();
4834    Some(format!(
4835        "{{{}-{}-{}-{}-{}}}",
4836        &r[0..8],
4837        &r[8..12],
4838        &r[12..16],
4839        &r[16..20],
4840        &r[20..32]
4841    ))
4842}
4843
4844#[cfg(not(windows))]
4845fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4846    Ok(Vec::new())
4847}
4848
4849#[cfg(windows)]
4850fn collect_uninstall_view(
4851    packages: &mut Vec<InstalledPackage>,
4852    seen: &mut BTreeSet<String>,
4853    upgrade_code_map: &HashMap<String, String>,
4854    root: RegKey,
4855    scope: &str,
4856    arch: &str,
4857    flags: u32,
4858) -> Result<()> {
4859    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4860
4861    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4862        Ok(key) => key,
4863        Err(_) => return Ok(()),
4864    };
4865
4866    for key_name in uninstall.enum_keys().flatten() {
4867        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4868            Ok(key) => key,
4869            Err(_) => continue,
4870        };
4871        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4872        {
4873            continue;
4874        }
4875
4876        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4877            continue;
4878        };
4879
4880        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4881        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4882        let publisher = read_reg_string(&subkey, "Publisher");
4883        let install_location = read_reg_string(&subkey, "InstallLocation");
4884        let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier");
4885        let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier");
4886        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4887            .into_iter()
4888            .collect::<Vec<_>>();
4889        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4890        if !product_codes.iter().any(|code| code.eq_ignore_ascii_case(&key_name)) {
4891            product_codes.push(key_name.to_ascii_lowercase());
4892        }
4893        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4894        if upgrade_codes.is_empty() {
4895            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
4896            // Installer registry hive so identity correlation can fall back to
4897            // upgradecodes2 when productcodes2 picks the wrong sibling.
4898            for code in &product_codes {
4899                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4900                    upgrade_codes.push(uc.clone());
4901                    break;
4902                }
4903            }
4904        }
4905        let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4906            .as_deref()
4907            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4908        {
4909            // Honor the WinGet ARP signal so uninstall flows through
4910            // uninstall_portable (clean dir + registry removal) instead of
4911            // delegating the WinGet portable UninstallString to a winget
4912            // subprocess that only fully cleans up entries winget itself
4913            // installed.
4914            Some("portable".to_owned())
4915        } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4916            Some("msi".to_owned())
4917        } else if key_name.starts_with("MSIX\\") {
4918            Some("msix".to_owned())
4919        } else {
4920            Some("exe".to_owned())
4921        };
4922
4923        let dedupe_key = format!(
4924            "{}|{}|{}|{}",
4925            local_id,
4926            name.to_ascii_lowercase(),
4927            installed_version.to_ascii_lowercase(),
4928            publisher.clone().unwrap_or_default().to_ascii_lowercase()
4929        );
4930        if !seen.insert(dedupe_key) {
4931            continue;
4932        }
4933
4934        packages.push(InstalledPackage {
4935            name,
4936            local_id,
4937            installed_version,
4938            publisher,
4939            scope: Some(scope.to_owned()),
4940            installer_category,
4941            install_location,
4942            winget_package_identifier,
4943            winget_source_identifier,
4944            package_family_names,
4945            product_codes,
4946            upgrade_codes,
4947            correlated: None,
4948            installed_version_canonical: false,
4949            correlated_requires_explicit_upgrade: false,
4950            correlated_lacks_compatible_installer: false,
4951        });
4952    }
4953
4954    Ok(())
4955}
4956
4957#[cfg(windows)]
4958fn collect_appmodel_packages(
4959    packages: &mut Vec<InstalledPackage>,
4960    seen: &mut BTreeSet<String>,
4961    root: RegKey,
4962    scope: &str,
4963    flags: u32,
4964) -> Result<()> {
4965    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4966        Ok(key) => key,
4967        Err(_) => return Ok(()),
4968    };
4969
4970    for key_name in appmodel.enum_keys().flatten() {
4971        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4972            Ok(key) => key,
4973            Err(_) => continue,
4974        };
4975
4976        let install_location = read_reg_string(&subkey, "PackageRootFolder");
4977        if install_location.as_deref().is_some_and(is_windows_system_path) {
4978            continue;
4979        }
4980
4981        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4982            continue;
4983        };
4984        if is_msix_split_resource_package(&metadata.resource_id) {
4985            continue;
4986        }
4987
4988        let Some(name) = read_reg_string(&subkey, "DisplayName")
4989            .or_else(|| read_appmodel_display_name(&subkey))
4990            .filter(|value| !value.is_empty())
4991        else {
4992            continue;
4993        };
4994
4995        let local_id = format!(r"MSIX\{key_name}");
4996        let dedupe_key = format!(
4997            "{}|{}|{}",
4998            local_id,
4999            name.to_ascii_lowercase(),
5000            metadata.version.to_ascii_lowercase()
5001        );
5002        if !seen.insert(dedupe_key) {
5003            continue;
5004        }
5005
5006        packages.push(InstalledPackage {
5007            name,
5008            local_id,
5009            installed_version: metadata.version,
5010            publisher: None,
5011            scope: Some(scope.to_owned()),
5012            installer_category: Some("msix".to_owned()),
5013            install_location,
5014            winget_package_identifier: None,
5015            winget_source_identifier: None,
5016            package_family_names: vec![metadata.family_name],
5017            product_codes: Vec::new(),
5018            upgrade_codes: Vec::new(),
5019            correlated: None,
5020            installed_version_canonical: false,
5021            correlated_requires_explicit_upgrade: false,
5022            correlated_lacks_compatible_installer: false,
5023        });
5024    }
5025
5026    Ok(())
5027}
5028
5029#[cfg(windows)]
5030struct ParsedMsixPackageFullName {
5031    version: String,
5032    resource_id: String,
5033    family_name: String,
5034}
5035
5036#[cfg(windows)]
5037fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
5038    let segments = value.split('_').collect::<Vec<_>>();
5039    if segments.len() < 5 {
5040        return None;
5041    }
5042
5043    let name = segments[..segments.len() - 4].join("_");
5044    if name.is_empty() {
5045        return None;
5046    }
5047
5048    let version = segments[segments.len() - 4].trim();
5049    let resource_id = segments[segments.len() - 2].trim();
5050    let publisher_id = segments[segments.len() - 1].trim();
5051    if version.is_empty() || publisher_id.is_empty() {
5052        return None;
5053    }
5054
5055    let family_name = if resource_id.is_empty() {
5056        format!("{name}_{publisher_id}")
5057    } else {
5058        format!("{name}_{resource_id}_{publisher_id}")
5059    };
5060
5061    Some(ParsedMsixPackageFullName {
5062        version: version.to_owned(),
5063        resource_id: resource_id.to_owned(),
5064        family_name,
5065    })
5066}
5067
5068#[cfg(windows)]
5069fn read_appmodel_display_name(package_key: &RegKey) -> Option<String> {
5070    let app_key = package_key.open_subkey(r"App\Capabilities").ok()?;
5071    read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName"))
5072}
5073
5074#[cfg(windows)]
5075fn is_msix_split_resource_package(resource_id: &str) -> bool {
5076    resource_id.trim().to_ascii_lowercase().starts_with("split.")
5077}
5078
5079#[cfg(windows)]
5080fn is_windows_system_path(path: &str) -> bool {
5081    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
5082}
5083
5084#[cfg(windows)]
5085fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
5086    key.get_value::<String, _>(value_name)
5087        .ok()
5088        .map(|value| value.trim().to_owned())
5089        .filter(|value| !value.is_empty())
5090}
5091
5092#[cfg(windows)]
5093fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
5094    key.get_value::<u32, _>(value_name).ok()
5095}
5096
5097fn ensure_app_dirs(app_root: &Path) -> Result<()> {
5098    fs::create_dir_all(app_root).context("failed to create app root directory")?;
5099    if uses_packaged_layout(app_root) {
5100        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
5101        let user_sources_path = packaged_user_sources_path(app_root)?;
5102        if let Some(parent) = user_sources_path.parent() {
5103            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
5104        }
5105    } else {
5106        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
5107    }
5108    Ok(())
5109}
5110
5111fn default_app_root() -> Result<PathBuf> {
5112    if let Some(app_root) = std::env::var_os(APP_ROOT_ENV_VAR) {
5113        return Ok(PathBuf::from(app_root));
5114    }
5115
5116    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
5117
5118    #[cfg(windows)]
5119    {
5120        if has_current_package_identity() {
5121            return Ok(local_app_data
5122                .join("Packages")
5123                .join(PACKAGED_FAMILY_NAME)
5124                .join("LocalState"));
5125        }
5126        Ok(local_app_data.join("Devolutions").join("Pinget"))
5127    }
5128
5129    #[cfg(not(windows))]
5130    {
5131        Ok(local_app_data.join("pinget"))
5132    }
5133}
5134
5135#[cfg(windows)]
5136fn has_current_package_identity() -> bool {
5137    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
5138    // process has no AppX package identity.
5139    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
5140
5141    let mut length: u32 = 0;
5142    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
5143    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
5144    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
5145    rc != APPMODEL_ERROR_NO_PACKAGE
5146}
5147
5148fn store_path(app_root: &Path) -> PathBuf {
5149    app_root.join("sources.json")
5150}
5151
5152fn user_settings_path(app_root: &Path) -> PathBuf {
5153    if uses_packaged_layout(app_root) {
5154        app_root.join("settings.json")
5155    } else {
5156        app_root.join("user-settings.json")
5157    }
5158}
5159
5160fn admin_settings_path(app_root: &Path) -> PathBuf {
5161    app_root.join("admin-settings.json")
5162}
5163
5164#[cfg(test)]
5165fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
5166    source_state_dir_with_keying(app_root, source, false)
5167}
5168
5169fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5170    if uses_packaged_layout(app_root) {
5171        return app_root
5172            .join(packaged_source_type(source.kind))
5173            .join(sanitize_path_segment(&source.identifier));
5174    }
5175
5176    if identifier_keyed {
5177        return app_root.join("sources").join(sanitize_path_segment(&source.identifier));
5178    }
5179
5180    app_root.join("sources").join(sanitize_path_segment(&source.name))
5181}
5182
5183fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5184    if uses_packaged_layout(app_root) {
5185        return app_root
5186            .join(sanitize_path_segment(&source.identifier))
5187            .join("installed.db");
5188    }
5189
5190    source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db")
5191}
5192
5193#[cfg(test)]
5194fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
5195    source_state_dir(app_root, source).join("index.db")
5196}
5197
5198fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) {
5199    if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) {
5200        source.etag = Some(value.to_owned());
5201    }
5202
5203    if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) {
5204        source.last_modified = Some(value.to_owned());
5205    }
5206}
5207
5208/// Removes the `-wal` and `-shm` sidecars that SQLite/Turso leaves next to a
5209/// database file. Used after replacing `index.db` so a stale write-ahead log
5210/// from the previous database is not replayed against the new one.
5211fn remove_sqlite_sidecars(db_path: &Path) {
5212    for suffix in ["-wal", "-shm"] {
5213        let mut sidecar = db_path.as_os_str().to_owned();
5214        sidecar.push(suffix);
5215        let _ = fs::remove_file(PathBuf::from(sidecar));
5216    }
5217}
5218
5219#[cfg(windows)]
5220const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
5221
5222#[cfg(windows)]
5223fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
5224    let source_wide = source
5225        .as_os_str()
5226        .encode_wide()
5227        .chain(std::iter::once(0))
5228        .collect::<Vec<_>>();
5229    let target_wide = target
5230        .as_os_str()
5231        .encode_wide()
5232        .chain(std::iter::once(0))
5233        .collect::<Vec<_>>();
5234    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
5235    // SAFETY: Both paths are converted to null-terminated UTF-16 buffers that
5236    // live for the duration of the call; MoveFileExW does not retain them.
5237    let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
5238    if ok == 0 {
5239        return Err(std::io::Error::last_os_error());
5240    }
5241    Ok(())
5242}
5243
5244#[cfg(windows)]
5245fn replace_file(source: &Path, target: &Path) -> Result<()> {
5246    let to_err = |error: std::io::Error| -> anyhow::Error {
5247        anyhow::Error::new(error).context(format!(
5248            "failed to replace {} with {}",
5249            target.display(),
5250            source.display()
5251        ))
5252    };
5253
5254    // Fast path: atomic replace. Succeeds whenever nothing holds the target open.
5255    let first = match move_replace_existing(source, target) {
5256        Ok(()) => return Ok(()),
5257        Err(error) => error,
5258    };
5259
5260    // A missing source or absent target is not a transient lock — fail fast
5261    // instead of spinning through the retry/stage-aside path.
5262    if !source.exists() || !target.exists() {
5263        return Err(to_err(first));
5264    }
5265
5266    // MOVEFILE_REPLACE_EXISTING is denied while another handle holds the target
5267    // open. In practice that handle is an on-access AV scan of the freshly
5268    // written file or a concurrent pinget process reading the index; both are
5269    // short-lived, so retry briefly with a small backoff before falling back.
5270    let mut last = first;
5271    for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
5272        std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
5273        match move_replace_existing(source, target) {
5274            Ok(()) => return Ok(()),
5275            Err(error) => last = error,
5276        }
5277    }
5278
5279    // Persistent holder: stage the existing index aside, then move the new file
5280    // into the freed name. Renaming the existing file succeeds even while a
5281    // reader keeps its handle, because SQLite/Turso opens with FILE_SHARE_DELETE.
5282    // The staged copy is only removed once the new file is in place, and restored
5283    // if the swap fails, so a working file is never lost.
5284    let staged = target.with_extension("old.tmp");
5285    if move_replace_existing(target, &staged).is_err() {
5286        return Err(to_err(last));
5287    }
5288    match move_replace_existing(source, target) {
5289        Ok(()) => {
5290            let _ = fs::remove_file(&staged);
5291            Ok(())
5292        }
5293        Err(error) => {
5294            let _ = move_replace_existing(&staged, target);
5295            Err(to_err(error))
5296        }
5297    }
5298}
5299
5300#[cfg(not(windows))]
5301fn replace_file(source: &Path, target: &Path) -> Result<()> {
5302    fs::rename(source, target)
5303        .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
5304}
5305
5306fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
5307    cache_root(app_root)
5308        .join(bucket)
5309        .join(sanitize_path_segment(identifier))
5310}
5311
5312fn load_store(app_root: &Path) -> Result<SourceStore> {
5313    if uses_packaged_layout(app_root)
5314        && let Some(store) = load_packaged_store(app_root)?
5315    {
5316        return Ok(store);
5317    }
5318
5319    let path = store_path(app_root);
5320    if !path.exists() {
5321        let store = SourceStore::default();
5322        save_store(app_root, &store)?;
5323        return Ok(store);
5324    }
5325
5326    let bytes = fs::read(path).context("failed to read source store")?;
5327    serde_json::from_slice(&bytes).context("failed to parse source store")
5328}
5329
5330fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> {
5331    if uses_system_winget_source_commands(app_root) {
5332        return Ok((
5333            EffectiveSourceMode::SystemWingetDirect,
5334            load_system_winget_source_store()?,
5335        ));
5336    }
5337
5338    match source_mode {
5339        SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5340        SourceMode::SystemWingetMirror => {
5341            let store = load_or_refresh_system_winget_mirror_store(app_root, false)?;
5342            Ok((EffectiveSourceMode::SystemWingetMirror, store))
5343        }
5344        SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) {
5345            Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)),
5346            Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5347        },
5348    }
5349}
5350
5351fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf {
5352    app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME)
5353}
5354
5355fn load_system_winget_mirror_store(app_root: &Path) -> Result<Option<SourceStore>> {
5356    let path = system_winget_mirror_store_path(app_root);
5357    if !path.exists() {
5358        return Ok(None);
5359    }
5360
5361    let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?;
5362    serde_json::from_slice(&bytes)
5363        .context("failed to parse system WinGet mirror source store")
5364        .map(Some)
5365}
5366
5367fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5368    write_json(system_winget_mirror_store_path(app_root), store)
5369}
5370
5371/// Freshness follows a stamp that only a successful `winget source export` writes. The
5372/// mirror store file itself is rewritten by ordinary pre-indexed metadata saves, so its
5373/// timestamp would let index activity keep a stale source list looking fresh forever.
5374fn system_winget_mirror_is_fresh(app_root: &Path, max_age: StdDuration) -> bool {
5375    if !system_winget_mirror_store_path(app_root).exists() {
5376        return false;
5377    }
5378
5379    fs::metadata(system_winget_mirror_export_stamp_path(app_root))
5380        .and_then(|metadata| metadata.modified())
5381        .ok()
5382        .and_then(|modified| modified.elapsed().ok())
5383        .is_some_and(|age| age < max_age)
5384}
5385
5386fn system_winget_mirror_export_stamp_path(app_root: &Path) -> PathBuf {
5387    app_root.join(SYSTEM_WINGET_MIRROR_EXPORT_STAMP_FILE_NAME)
5388}
5389
5390/// A stamp that cannot be written only costs the export gate, so it must not fail the
5391/// export that just succeeded.
5392fn stamp_system_winget_mirror_export(app_root: &Path) {
5393    let _ = fs::write(
5394        system_winget_mirror_export_stamp_path(app_root),
5395        Utc::now().to_rfc3339(),
5396    );
5397}
5398
5399fn system_winget_source_refresh_warning(error: &anyhow::Error) -> String {
5400    format!("Could not refresh the system WinGet sources; using the sources Pinget cached earlier. {error:#}")
5401}
5402
5403fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result<SourceStore> {
5404    if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? {
5405        return Ok(store);
5406    }
5407
5408    refresh_system_winget_mirror_store(app_root)
5409}
5410
5411fn refresh_system_winget_mirror_store(app_root: &Path) -> Result<SourceStore> {
5412    let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default();
5413    let mut exported = load_system_winget_source_store()?;
5414    merge_source_cache_metadata(&mut exported, &prior);
5415    save_system_winget_mirror_store(app_root, &exported)?;
5416    stamp_system_winget_mirror_export(app_root);
5417    Ok(exported)
5418}
5419
5420fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) {
5421    let prior_by_identifier: HashMap<_, _> = prior
5422        .sources
5423        .iter()
5424        .map(|source| (source.identifier.to_ascii_lowercase(), source))
5425        .collect();
5426
5427    for source in &mut target.sources {
5428        let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else {
5429            continue;
5430        };
5431        source.last_update = previous.last_update;
5432        source.source_version = previous.source_version.clone();
5433        source.etag = previous.etag.clone();
5434        source.last_modified = previous.last_modified.clone();
5435    }
5436}
5437
5438fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5439    if uses_system_winget_source_commands(app_root) {
5440        bail!("System WinGet source settings must be modified through winget.");
5441    }
5442
5443    if uses_packaged_layout(app_root) {
5444        return save_packaged_store(app_root, store);
5445    }
5446
5447    write_json(store_path(app_root), store)
5448}
5449
5450fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
5451    let user_sources_path = packaged_user_sources_path(app_root)?;
5452    let metadata_path = app_root.join("sources_metadata");
5453    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
5454    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
5455    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
5456}
5457
5458fn load_packaged_store_from_streams(
5459    user_sources_yaml: Option<&str>,
5460    metadata_yaml: Option<&str>,
5461) -> Result<Option<SourceStore>> {
5462    if is_secure_settings_stub(user_sources_yaml) {
5463        return load_system_winget_source_store().map(Some);
5464    }
5465
5466    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
5467}
5468
5469fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5470    let user_sources_path = packaged_user_sources_path(app_root)?;
5471    if let Some(parent) = user_sources_path.parent() {
5472        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
5473    }
5474
5475    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
5476        .context("failed to write packaged user_sources stream")?;
5477    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
5478        .context("failed to write packaged source metadata")?;
5479    Ok(())
5480}
5481
5482fn uses_system_winget_source_commands(app_root: &Path) -> bool {
5483    if !uses_packaged_layout(app_root) {
5484        return false;
5485    }
5486
5487    #[cfg(windows)]
5488    {
5489        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
5490            return false;
5491        };
5492
5493        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
5494        is_secure_settings_stub(user_sources_yaml.as_deref())
5495    }
5496
5497    #[cfg(not(windows))]
5498    {
5499        false
5500    }
5501}
5502
5503fn is_secure_settings_stub(value: Option<&str>) -> bool {
5504    value
5505        .map(str::trim_start)
5506        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
5507}
5508
5509fn load_system_winget_source_store() -> Result<SourceStore> {
5510    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
5511    Ok(SourceStore {
5512        sources: parse_system_winget_source_export(&result.stdout)?,
5513    })
5514}
5515
5516fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
5517    run_checked_winget_source_command(
5518        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
5519        &format!("add WinGet source '{name}'"),
5520    )?;
5521    Ok(())
5522}
5523
5524fn system_winget_remove_source(name: &str) -> Result<()> {
5525    run_checked_winget_source_command(
5526        vec![
5527            "source".to_owned(),
5528            "remove".to_owned(),
5529            "--name".to_owned(),
5530            name.to_owned(),
5531            "--disable-interactivity".to_owned(),
5532        ],
5533        &format!("remove WinGet source '{name}'"),
5534    )?;
5535    Ok(())
5536}
5537
5538fn system_winget_reset_source(name: &str) -> Result<()> {
5539    run_checked_winget_source_command(
5540        vec![
5541            "source".to_owned(),
5542            "reset".to_owned(),
5543            "--name".to_owned(),
5544            name.to_owned(),
5545            "--force".to_owned(),
5546            "--disable-interactivity".to_owned(),
5547        ],
5548        &format!("reset WinGet source '{name}'"),
5549    )?;
5550    Ok(())
5551}
5552
5553fn system_winget_reset_sources() -> Result<()> {
5554    run_checked_winget_source_command(
5555        vec![
5556            "source".to_owned(),
5557            "reset".to_owned(),
5558            "--force".to_owned(),
5559            "--disable-interactivity".to_owned(),
5560        ],
5561        "reset WinGet sources",
5562    )?;
5563    Ok(())
5564}
5565
5566fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
5567    let mut args = vec!["source".to_owned(), "update".to_owned()];
5568    if let Some(name) = name {
5569        args.push("--name".to_owned());
5570        args.push(name.to_owned());
5571    }
5572
5573    args.push("--disable-interactivity".to_owned());
5574    let result = run_checked_winget_source_command(
5575        args,
5576        if name.is_some() {
5577            "update WinGet source"
5578        } else {
5579            "update WinGet sources"
5580        },
5581    )?;
5582    let detail = result.stdout.trim();
5583    Ok(if detail.is_empty() {
5584        "Done".to_owned()
5585    } else {
5586        detail.to_owned()
5587    })
5588}
5589
5590fn build_winget_source_export_arguments() -> Vec<String> {
5591    vec![
5592        "source".to_owned(),
5593        "export".to_owned(),
5594        "--disable-interactivity".to_owned(),
5595    ]
5596}
5597
5598fn build_winget_source_add_arguments(
5599    name: &str,
5600    arg: &str,
5601    kind: SourceKind,
5602    trust_level: &str,
5603    explicit: bool,
5604) -> Vec<String> {
5605    let mut args = vec![
5606        "source".to_owned(),
5607        "add".to_owned(),
5608        "--name".to_owned(),
5609        name.to_owned(),
5610        "--arg".to_owned(),
5611        arg.to_owned(),
5612        "--type".to_owned(),
5613        packaged_source_type(kind).to_owned(),
5614        "--disable-interactivity".to_owned(),
5615    ];
5616
5617    if !trust_level.trim().is_empty() {
5618        args.push("--trust-level".to_owned());
5619        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
5620            "trusted".to_owned()
5621        } else {
5622            "none".to_owned()
5623        });
5624    }
5625
5626    if explicit {
5627        args.push("--explicit".to_owned());
5628    }
5629
5630    args
5631}
5632
5633#[derive(Debug)]
5634struct WingetSourceCommandResult {
5635    exit_code: i32,
5636    stdout: String,
5637    stderr: String,
5638}
5639
5640type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
5641
5642static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
5643    RwLock::new(run_winget_source_command_process);
5644
5645fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
5646    let result = run_winget_source_command(&args)?;
5647    if result.exit_code == 0 {
5648        return Ok(result);
5649    }
5650
5651    let detail = [result.stderr.trim(), result.stdout.trim()]
5652        .into_iter()
5653        .filter(|value| !value.is_empty())
5654        .collect::<Vec<_>>()
5655        .join("\n");
5656    if detail.is_empty() {
5657        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
5658    }
5659
5660    bail!(
5661        "Failed to {action}; winget exited with code {}:\n{}",
5662        result.exit_code,
5663        detail
5664    );
5665}
5666
5667fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
5668    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
5669        .read()
5670        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
5671    runner(args)
5672}
5673
5674fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
5675    use std::process::Command;
5676
5677    let program = resolve_system_winget_program()?;
5678    let output = Command::new(&program)
5679        .args(args)
5680        .output()
5681        .with_context(|| format!("failed to run the WinGet source command with {}", program.display()))?;
5682
5683    Ok(WingetSourceCommandResult {
5684        exit_code: output.status.code().unwrap_or(-1),
5685        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5686        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5687    })
5688}
5689
5690fn resolve_system_winget_program() -> Result<PathBuf> {
5691    resolve_system_winget_program_from(
5692        std::env::var_os(SYSTEM_WINGET_PROGRAM_ENV_VAR),
5693        current_executable_directory(),
5694        std::env::var_os("PATH"),
5695        &system_winget_fallback_directories,
5696        &|candidate| candidate.is_file(),
5697    )
5698}
5699
5700fn current_executable_directory() -> Option<PathBuf> {
5701    std::env::current_exe()
5702        .ok()
5703        .and_then(|executable| executable.parent().map(Path::to_path_buf))
5704}
5705
5706/// WinGet ships as an App Execution Alias, so a host that inherited a PATH without
5707/// %LOCALAPPDATA%\Microsoft\WindowsApps cannot spawn it by name at all. Look past the
5708/// PATH before giving up, and let a host that already knows the location say so.
5709///
5710/// The directory of the running executable keeps the precedence it had while this spawned
5711/// `winget` by name: Windows resolves a bare program name against the application directory
5712/// before the PATH, and a host that ships its own copy relies on that.
5713fn resolve_system_winget_program_from(
5714    configured: Option<OsString>,
5715    application_directory: Option<PathBuf>,
5716    search_path: Option<OsString>,
5717    fallback_directories: &dyn Fn() -> Vec<PathBuf>,
5718    is_file: &dyn Fn(&Path) -> bool,
5719) -> Result<PathBuf> {
5720    if let Some(configured) = configured.filter(|value| !value.to_string_lossy().trim().is_empty()) {
5721        let configured = PathBuf::from(configured);
5722        if is_file(&configured) {
5723            return Ok(configured);
5724        }
5725
5726        let nested = configured.join(SYSTEM_WINGET_PROGRAM_NAME);
5727        if is_file(&nested) {
5728            return Ok(nested);
5729        }
5730
5731        bail!(
5732            "{SYSTEM_WINGET_PROGRAM_ENV_VAR} is set to {}, where no {SYSTEM_WINGET_PROGRAM_NAME} was found.",
5733            configured.display()
5734        );
5735    }
5736
5737    if let Some(program) = first_system_winget_program(application_directory, is_file) {
5738        return Ok(program);
5739    }
5740
5741    let search_directories = search_path
5742        .as_deref()
5743        .map(|value| std::env::split_paths(value).collect::<Vec<_>>())
5744        .unwrap_or_default();
5745
5746    if let Some(program) = first_system_winget_program(search_directories, is_file) {
5747        return Ok(program);
5748    }
5749
5750    // Enumerating the App Installer package locations reads the registry, so it stays
5751    // behind the PATH: the machines that already resolve winget by name pay nothing.
5752    if let Some(program) = first_system_winget_program(fallback_directories(), is_file) {
5753        return Ok(program);
5754    }
5755
5756    bail!(
5757        "{SYSTEM_WINGET_PROGRAM_NAME} was not found on the PATH or in the App Installer install locations. Set {SYSTEM_WINGET_PROGRAM_ENV_VAR} to its full path, or install the App Installer."
5758    )
5759}
5760
5761fn first_system_winget_program(
5762    directories: impl IntoIterator<Item = PathBuf>,
5763    is_file: &dyn Fn(&Path) -> bool,
5764) -> Option<PathBuf> {
5765    directories.into_iter().find_map(|directory| {
5766        let candidate = directory.join(SYSTEM_WINGET_PROGRAM_NAME);
5767        is_file(&candidate).then_some(candidate)
5768    })
5769}
5770
5771#[cfg(windows)]
5772fn system_winget_fallback_directories() -> Vec<PathBuf> {
5773    let mut directories = Vec::new();
5774    if let Some(local_app_data) = dirs::data_local_dir() {
5775        directories.push(local_app_data.join("Microsoft").join("WindowsApps"));
5776    }
5777
5778    directories.extend(app_installer_package_directories());
5779    directories
5780}
5781
5782#[cfg(not(windows))]
5783fn system_winget_fallback_directories() -> Vec<PathBuf> {
5784    Vec::new()
5785}
5786
5787#[cfg(windows)]
5788fn app_installer_package_directories() -> Vec<PathBuf> {
5789    let Ok(packages) = RegKey::predef(HKEY_CURRENT_USER).open_subkey(APPMODEL_PACKAGES_PATH) else {
5790        return Vec::new();
5791    };
5792
5793    let mut found: Vec<(String, PathBuf)> = Vec::new();
5794    for key_name in packages.enum_keys().flatten() {
5795        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
5796            continue;
5797        };
5798        if metadata.family_name != PACKAGED_FAMILY_NAME || is_msix_split_resource_package(&metadata.resource_id) {
5799            continue;
5800        }
5801
5802        let Ok(entry) = packages.open_subkey(&key_name) else {
5803            continue;
5804        };
5805        let Some(package_root) = read_reg_string(&entry, "PackageRootFolder") else {
5806            continue;
5807        };
5808
5809        found.push((metadata.version, PathBuf::from(package_root)));
5810    }
5811
5812    found.sort_by(|left, right| compare_version(&right.0, &left.0));
5813    found.into_iter().map(|(_, directory)| directory).collect()
5814}
5815
5816fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
5817    let trimmed = output.trim();
5818    if trimmed.is_empty() {
5819        return Ok(Vec::new());
5820    }
5821
5822    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
5823        && let Some(sources) = parse_system_winget_source_export_value(&value)?
5824    {
5825        return Ok(sources);
5826    }
5827
5828    let mut sources = Vec::new();
5829    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
5830        if line.is_empty() {
5831            continue;
5832        }
5833
5834        let value: JsonValue = serde_json::from_str(line)
5835            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
5836        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
5837            bail!("WinGet source export returned a non-source line: {line}");
5838        };
5839        sources.append(&mut parsed);
5840    }
5841
5842    Ok(sources)
5843}
5844
5845fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
5846    match value {
5847        JsonValue::Array(items) => items
5848            .iter()
5849            .map(parse_system_winget_source_record)
5850            .collect::<Result<Vec<_>>>()
5851            .map(Some),
5852        JsonValue::Object(object) => {
5853            if let Some(JsonValue::Array(items)) = object.get("Sources") {
5854                items
5855                    .iter()
5856                    .map(parse_system_winget_source_record)
5857                    .collect::<Result<Vec<_>>>()
5858                    .map(Some)
5859            } else if object.contains_key("Name") {
5860                Ok(Some(vec![parse_system_winget_source_record(value)?]))
5861            } else {
5862                Ok(None)
5863            }
5864        }
5865        _ => Ok(None),
5866    }
5867}
5868
5869fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
5870    let object = value
5871        .as_object()
5872        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
5873    let name = required_json_string(object, "Name")?;
5874    let source_type = required_json_string(object, "Type")?;
5875    let arg = required_json_string(object, "Arg")?;
5876    let kind = parse_system_winget_source_kind(&source_type)?;
5877    let identifier = optional_json_string(object, "Identifier")
5878        .or_else(|| optional_json_string(object, "Data"))
5879        .unwrap_or_else(|| name.clone());
5880
5881    Ok(SourceRecord {
5882        name,
5883        kind,
5884        arg,
5885        identifier,
5886        trust_level: parse_system_winget_trust_level(object),
5887        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
5888        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
5889        last_update: None,
5890        source_version: None,
5891        etag: None,
5892        last_modified: None,
5893    })
5894}
5895
5896fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
5897    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
5898}
5899
5900fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
5901    match object.get(key)? {
5902        JsonValue::String(value) => Some(value.clone()),
5903        JsonValue::Number(value) => Some(value.to_string()),
5904        _ => None,
5905    }
5906}
5907
5908fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
5909    match object.get(key)? {
5910        JsonValue::Bool(value) => Some(*value),
5911        JsonValue::String(value) => value.parse().ok(),
5912        _ => None,
5913    }
5914}
5915
5916fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
5917    match object.get(key)? {
5918        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
5919        JsonValue::String(value) => value.parse().ok(),
5920        _ => None,
5921    }
5922}
5923
5924fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
5925    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5926        Ok(SourceKind::PreIndexed)
5927    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5928        Ok(SourceKind::Rest)
5929    } else {
5930        bail!("Unsupported WinGet source type '{value}'.")
5931    }
5932}
5933
5934fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
5935    let Some(value) = object.get("TrustLevel") else {
5936        return "None".to_owned();
5937    };
5938
5939    match value {
5940        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
5941        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
5942        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
5943        _ => "None".to_owned(),
5944    }
5945}
5946
5947fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
5948    value
5949        .as_str()
5950        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
5951}
5952
5953fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
5954    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
5955        return None;
5956    }
5957
5958    let mut sources = SourceStore::default().sources;
5959    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
5960        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5961            continue;
5962        };
5963
5964        if yaml_bool(entry.get("IsTombstone")) {
5965            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
5966            continue;
5967        }
5968
5969        let Some(mapped) = map_packaged_source_entry(&entry) else {
5970            continue;
5971        };
5972
5973        if let Some(existing) = sources
5974            .iter_mut()
5975            .find(|source| source.name.eq_ignore_ascii_case(&name))
5976        {
5977            *existing = mapped;
5978        } else {
5979            sources.push(mapped);
5980        }
5981    }
5982
5983    for entry in parse_packaged_yaml_entries(metadata_yaml) {
5984        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5985            continue;
5986        };
5987
5988        let Some(source) = sources
5989            .iter_mut()
5990            .find(|source| source.name.eq_ignore_ascii_case(&name))
5991        else {
5992            continue;
5993        };
5994
5995        if let Some(last_update) = entry
5996            .get("LastUpdate")
5997            .and_then(|value| value.as_deref())
5998            .and_then(parse_packaged_datetime)
5999        {
6000            source.last_update = Some(last_update);
6001        }
6002
6003        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
6004            && !source_version.is_empty()
6005        {
6006            source.source_version = Some(source_version);
6007        }
6008
6009        if let Some(etag) = entry.get("ETag").and_then(|value| value.clone())
6010            && !etag.is_empty()
6011        {
6012            source.etag = Some(etag);
6013        }
6014
6015        if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone())
6016            && !last_modified.is_empty()
6017        {
6018            source.last_modified = Some(last_modified);
6019        }
6020    }
6021
6022    Some(SourceStore { sources })
6023}
6024
6025fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
6026    let Some(yaml) = yaml else {
6027        return Vec::new();
6028    };
6029
6030    let mut entries = Vec::new();
6031    let mut current: Option<BTreeMap<String, Option<String>>> = None;
6032    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
6033        let line = raw_line.trim_end();
6034        let trimmed = line.trim();
6035        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
6036            continue;
6037        }
6038
6039        if let Some(remainder) = trimmed.strip_prefix("- ") {
6040            if let Some(entry) = current.take() {
6041                entries.push(entry);
6042            }
6043
6044            let mut entry = BTreeMap::new();
6045            add_packaged_yaml_key_value(&mut entry, remainder);
6046            current = Some(entry);
6047            continue;
6048        }
6049
6050        if let Some(entry) = current.as_mut() {
6051            add_packaged_yaml_key_value(entry, trimmed);
6052        }
6053    }
6054
6055    if let Some(entry) = current {
6056        entries.push(entry);
6057    }
6058
6059    entries
6060}
6061
6062fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
6063    let Some((key, value)) = line.split_once(':') else {
6064        return;
6065    };
6066
6067    entry.insert(
6068        key.trim().to_owned(),
6069        Some(unquote_yaml_scalar(value.trim()).to_owned()),
6070    );
6071}
6072
6073fn unquote_yaml_scalar(value: &str) -> &str {
6074    if value.len() >= 2 {
6075        let bytes = value.as_bytes();
6076        let first = bytes[0] as char;
6077        let last = bytes[value.len() - 1] as char;
6078        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
6079            return &value[1..value.len() - 1];
6080        }
6081    }
6082
6083    value
6084}
6085
6086fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
6087    let name = entry.get("Name").and_then(|value| value.clone())?;
6088    let source_type = entry.get("Type").and_then(|value| value.clone())?;
6089    let arg = entry.get("Arg").and_then(|value| value.clone())?;
6090    let kind = parse_packaged_source_kind(&source_type)?;
6091
6092    Some(SourceRecord {
6093        name: name.clone(),
6094        kind,
6095        arg,
6096        identifier: entry
6097            .get("Identifier")
6098            .and_then(|value| value.clone())
6099            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
6100            .unwrap_or(name),
6101        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
6102        explicit: yaml_bool(entry.get("Explicit")),
6103        priority: entry
6104            .get("Priority")
6105            .and_then(|value| value.as_deref())
6106            .and_then(|value| value.parse::<i32>().ok())
6107            .unwrap_or_default(),
6108        last_update: None,
6109        source_version: None,
6110        etag: None,
6111        last_modified: None,
6112    })
6113}
6114
6115fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
6116    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
6117        Some(SourceKind::PreIndexed)
6118    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
6119        Some(SourceKind::Rest)
6120    } else {
6121        None
6122    }
6123}
6124
6125fn parse_packaged_trust_level(value: Option<&str>) -> String {
6126    match value {
6127        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
6128        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
6129        _ => "None".to_owned(),
6130    }
6131}
6132
6133fn yaml_bool(value: Option<&Option<String>>) -> bool {
6134    value
6135        .and_then(|raw| raw.as_deref())
6136        .and_then(|raw| raw.parse::<bool>().ok())
6137        .unwrap_or(false)
6138}
6139
6140fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
6141    if let Ok(unix_seconds) = value.parse::<i64>() {
6142        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
6143    }
6144
6145    DateTime::parse_from_rfc3339(value)
6146        .ok()
6147        .map(|value| value.with_timezone(&Utc))
6148}
6149
6150fn render_packaged_sources_yaml(store: &SourceStore) -> String {
6151    let mut lines = vec!["Sources:".to_owned()];
6152    for source in &store.sources {
6153        lines.push(format!("  - Name: {}", source.name));
6154        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
6155        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
6156        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
6157        lines.push(format!(
6158            "    TrustLevel: {}",
6159            if source.trust_level.eq_ignore_ascii_case("Trusted") {
6160                1
6161            } else {
6162                0
6163            }
6164        ));
6165        lines.push(format!("    Explicit: {}", source.explicit));
6166        lines.push(format!("    Priority: {}", source.priority));
6167        lines.push("    IsTombstone: false".to_owned());
6168    }
6169    lines.push(String::new());
6170    lines.join("\n")
6171}
6172
6173fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
6174    let mut lines = vec!["Sources:".to_owned()];
6175    for source in &store.sources {
6176        if source.last_update.is_none()
6177            && source.source_version.is_none()
6178            && source.etag.is_none()
6179            && source.last_modified.is_none()
6180        {
6181            continue;
6182        }
6183
6184        lines.push(format!("  - Name: {}", source.name));
6185        if let Some(last_update) = source.last_update {
6186            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
6187        }
6188        if let Some(source_version) = &source.source_version {
6189            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
6190        }
6191        if let Some(etag) = &source.etag {
6192            lines.push(format!("    ETag: {}", yaml_scalar(etag)));
6193        }
6194        if let Some(last_modified) = &source.last_modified {
6195            lines.push(format!("    LastModified: {}", yaml_scalar(last_modified)));
6196        }
6197    }
6198    lines.push(String::new());
6199    lines.join("\n")
6200}
6201
6202fn yaml_scalar(value: &str) -> String {
6203    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
6204        format!("\"{}\"", value.replace('"', "\\\""))
6205    } else {
6206        value.to_owned()
6207    }
6208}
6209
6210fn uses_packaged_layout(app_root: &Path) -> bool {
6211    #[cfg(windows)]
6212    {
6213        try_get_packaged_local_state_root(app_root).is_some()
6214    }
6215
6216    #[cfg(not(windows))]
6217    {
6218        let _ = app_root;
6219        false
6220    }
6221}
6222
6223#[cfg(windows)]
6224fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
6225    let local_state = app_root.file_name()?.to_string_lossy();
6226    if !local_state.eq_ignore_ascii_case("LocalState") {
6227        return None;
6228    }
6229
6230    let family = app_root.parent()?.file_name()?.to_string_lossy();
6231    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
6232        return None;
6233    }
6234
6235    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
6236    if !packages.eq_ignore_ascii_case("Packages") {
6237        return None;
6238    }
6239
6240    Some(app_root.to_path_buf())
6241}
6242
6243#[cfg(windows)]
6244fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
6245    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
6246    Ok(packaged_secure_settings_root()?.join("user_sources"))
6247}
6248
6249#[cfg(not(windows))]
6250fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
6251    bail!("packaged WinGet source streams are only available on Windows")
6252}
6253
6254#[cfg(windows)]
6255fn packaged_secure_settings_root() -> Result<PathBuf> {
6256    let program_data = std::env::var_os("PROGRAMDATA")
6257        .map(PathBuf::from)
6258        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
6259    Ok(program_data
6260        .join("Microsoft")
6261        .join("WinGet")
6262        .join(current_user_sid()?)
6263        .join("settings")
6264        .join("pkg")
6265        .join(PACKAGED_NAME))
6266}
6267
6268#[cfg(windows)]
6269#[allow(clippy::multiple_unsafe_ops_per_block)]
6270fn current_user_sid() -> Result<String> {
6271    fn close_token(token: HANDLE) {
6272        if !token.is_null() {
6273            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
6274            unsafe {
6275                CloseHandle(token);
6276            }
6277        }
6278    }
6279
6280    let mut token: HANDLE = std::ptr::null_mut();
6281    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
6282    let current_process = unsafe { GetCurrentProcess() };
6283    // SAFETY: token points to valid storage for the process token handle on success.
6284    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
6285        bail!("failed to open current process token")
6286    }
6287
6288    let mut length = 0;
6289    // SAFETY: The null buffer call asks Windows for the required buffer length.
6290    unsafe {
6291        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
6292    }
6293
6294    let element_size = size_of::<TOKEN_USER>();
6295    let element_count = (length as usize).div_ceil(element_size);
6296    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
6297    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
6298    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
6299        close_token(token);
6300        bail!("failed to get current user token information")
6301    }
6302
6303    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
6304    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
6305    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
6306    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
6307    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
6308        close_token(token);
6309        bail!("failed to convert current user SID")
6310    }
6311
6312    let mut len = 0usize;
6313    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
6314    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
6315        len += 1;
6316    }
6317
6318    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
6319    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
6320    let sid = OsString::from_wide(sid_slice).to_string_lossy().into_owned();
6321    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
6322    unsafe {
6323        LocalFree(sid_ptr.cast());
6324    }
6325    close_token(token);
6326    Ok(sid)
6327}
6328
6329fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
6330    app_root.join("Microsoft").join("Windows Package Manager")
6331}
6332
6333fn cache_root(app_root: &Path) -> PathBuf {
6334    if uses_packaged_layout(app_root) {
6335        packaged_file_cache_root(app_root)
6336    } else {
6337        default_cache_root_fallback()
6338    }
6339}
6340
6341fn default_cache_root_fallback() -> PathBuf {
6342    std::env::temp_dir().join("cache")
6343}
6344
6345fn packaged_source_type(kind: SourceKind) -> &'static str {
6346    match kind {
6347        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
6348        SourceKind::Rest => "Microsoft.Rest",
6349    }
6350}
6351
6352fn sanitize_path_segment(value: &str) -> String {
6353    value
6354        .chars()
6355        .map(|ch| match ch {
6356            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
6357            _ => ch,
6358        })
6359        .collect()
6360}
6361
6362fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
6363    if !path.exists() {
6364        return Ok(JsonMap::new());
6365    }
6366
6367    let bytes = fs::read(path).context("failed to read JSON file")?;
6368    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
6369    Ok(match value {
6370        JsonValue::Object(object) => object,
6371        _ => JsonMap::new(),
6372    })
6373}
6374
6375fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
6376    write_json(path, value)
6377}
6378
6379fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
6380    if let Some(parent) = path.parent() {
6381        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
6382    }
6383
6384    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
6385    fs::write(path, bytes).context("failed to write JSON file")
6386}
6387
6388fn merge_json_objects(
6389    current: &JsonMap<String, JsonValue>,
6390    update: &JsonMap<String, JsonValue>,
6391) -> JsonMap<String, JsonValue> {
6392    let mut merged = current.clone();
6393    for (key, value) in update {
6394        match (merged.get(key), value) {
6395            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
6396                merged.insert(
6397                    key.clone(),
6398                    JsonValue::Object(merge_json_objects(current_object, update_object)),
6399                );
6400            }
6401            _ => {
6402                merged.insert(key.clone(), value.clone());
6403            }
6404        }
6405    }
6406
6407    merged
6408}
6409
6410fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
6411    match expected {
6412        JsonValue::Object(expected_object) => match current {
6413            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
6414                current_object
6415                    .get(key)
6416                    .is_some_and(|current_child| json_contains(current_child, expected_child))
6417            }),
6418            _ => false,
6419        },
6420        JsonValue::Array(expected_array) => match current {
6421            JsonValue::Array(current_array) => {
6422                current_array.len() == expected_array.len()
6423                    && current_array
6424                        .iter()
6425                        .zip(expected_array.iter())
6426                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
6427            }
6428            _ => false,
6429        },
6430        _ => current == expected,
6431    }
6432}
6433
6434fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
6435    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
6436        "none" | "default" => Ok("None".to_owned()),
6437        "trusted" => Ok("Trusted".to_owned()),
6438        other => bail!("unsupported source trust level: {other}"),
6439    }
6440}
6441
6442fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
6443    SUPPORTED_ADMIN_SETTINGS
6444        .iter()
6445        .copied()
6446        .find(|setting| setting.eq_ignore_ascii_case(name))
6447        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
6448}
6449
6450fn default_source_trust_level() -> String {
6451    "None".to_owned()
6452}
6453
6454fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
6455where
6456    F: FnMut(&SqlRow) -> Result<T>,
6457{
6458    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6459        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6460    let mut result = Vec::new();
6461
6462    while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
6463        result.push(map(&row)?);
6464    }
6465
6466    Ok(result)
6467}
6468
6469fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
6470where
6471    F: FnOnce(&SqlRow) -> Result<T>,
6472{
6473    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6474        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6475
6476    match pollster::block_on(rows.next()).context("failed to read SQL row")? {
6477        Some(row) => Ok(Some(map(&row)?)),
6478        None => Ok(None),
6479    }
6480}
6481
6482fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
6483    pollster::block_on(connection.execute(sql, params_from_iter(params)))
6484        .with_context(|| format!("failed to execute SQL statement: {sql}"))
6485}
6486
6487fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
6488    pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
6489}
6490
6491fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
6492    row.get_value(index)
6493        .with_context(|| format!("failed to read SQL column {index}"))
6494}
6495
6496fn row_string(row: &SqlRow, index: usize) -> Result<String> {
6497    match row_value(row, index)? {
6498        SqlValue::Text(value) => Ok(value),
6499        SqlValue::Integer(value) => Ok(value.to_string()),
6500        SqlValue::Real(value) => Ok(value.to_string()),
6501        SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
6502        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6503    }
6504}
6505
6506fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6507    match row_value(row, index)? {
6508        SqlValue::Null => Ok(None),
6509        SqlValue::Text(value) => Ok(Some(value)),
6510        SqlValue::Integer(value) => Ok(Some(value.to_string())),
6511        SqlValue::Real(value) => Ok(Some(value.to_string())),
6512        SqlValue::Blob(value) => Ok(Some(
6513            String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
6514        )),
6515    }
6516}
6517
6518fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
6519    match row_value(row, index)? {
6520        SqlValue::Integer(value) => Ok(value),
6521        SqlValue::Text(value) => value
6522            .parse::<i64>()
6523            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
6524        SqlValue::Real(value) => f64_to_i64_checked(value, index),
6525        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6526        SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
6527    }
6528}
6529
6530fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
6531    match row_value(row, index)? {
6532        SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
6533        SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
6534        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6535        SqlValue::Integer(value) => Ok(format!("{value:x}")),
6536        SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
6537    }
6538}
6539
6540fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6541    match row_value(row, index)? {
6542        SqlValue::Null => Ok(None),
6543        SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
6544        SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
6545        SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
6546        SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
6547    }
6548}
6549
6550fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
6551    if !value.is_finite() {
6552        bail!("SQL column {index} contained a non-finite floating-point value");
6553    }
6554
6555    if value.fract() != 0.0 {
6556        bail!("SQL column {index} contained a non-integer floating-point value");
6557    }
6558
6559    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
6560        bail!("SQL column {index} contained a floating-point value outside the i64 range");
6561    }
6562
6563    format!("{value:.0}")
6564        .parse::<i64>()
6565        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
6566}
6567
6568fn bytes_to_hex(bytes: &[u8]) -> String {
6569    let mut result = String::with_capacity(bytes.len() * 2);
6570    for byte in bytes {
6571        result.push_str(&format!("{byte:02x}"));
6572    }
6573    result
6574}
6575
6576fn query_v2_matches(
6577    connection: &Connection,
6578    query: &PackageQuery,
6579    semantics: SearchSemantics,
6580) -> Result<(Vec<V2SearchRow>, bool)> {
6581    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
6582    let limit = source_fetch_results(query, semantics);
6583    let sql = format!(
6584        "SELECT rowid, id, name, moniker, latest_version, hash \
6585         FROM packages WHERE {where_clause} LIMIT {}",
6586        limit + 1
6587    );
6588    let mut rows = query_rows(
6589        connection,
6590        &sql,
6591        params.into_iter().map(SqlValue::Text).collect(),
6592        |row| {
6593            Ok(V2SearchRow {
6594                package_rowid: row_i64(row, 0)?,
6595                id: row_string(row, 1)?,
6596                name: row_string(row, 2)?,
6597                moniker: row_opt_string(row, 3)?,
6598                version: row_string(row, 4)?,
6599                package_hash: row_hex_string(row, 5)?,
6600                match_criteria: None,
6601            })
6602        },
6603    )?;
6604    for row in &mut rows {
6605        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
6606    }
6607    let truncated = rows.len() > limit;
6608    rows.truncate(limit);
6609    Ok((rows, truncated))
6610}
6611
6612fn query_v1_matches(
6613    connection: &Connection,
6614    query: &PackageQuery,
6615    semantics: SearchSemantics,
6616) -> Result<(Vec<V1SearchRow>, bool)> {
6617    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
6618    let limit = source_fetch_results(query, semantics);
6619    let sql = format!(
6620        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
6621         FROM manifest \
6622         JOIN ids ON manifest.id = ids.rowid \
6623         JOIN names ON manifest.name = names.rowid \
6624         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
6625         JOIN versions ON manifest.version = versions.rowid \
6626         JOIN channels ON manifest.channel = channels.rowid \
6627         WHERE {where_clause} LIMIT {}",
6628        limit + 1
6629    );
6630    let mut rows = query_rows(
6631        connection,
6632        &sql,
6633        params.into_iter().map(SqlValue::Text).collect(),
6634        |row| {
6635            Ok(V1SearchRow {
6636                manifest_rowid: row_i64(row, 0)?,
6637                package_rowid: row_i64(row, 1)?,
6638                version: row_string(row, 2)?,
6639                channel: row_string(row, 3)?,
6640                id: row_string(row, 4)?,
6641                name: row_string(row, 5)?,
6642                moniker: row_opt_string(row, 6)?,
6643                match_criteria: None,
6644            })
6645        },
6646    )?;
6647    for row in &mut rows {
6648        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
6649    }
6650    let truncated = rows.len() > limit;
6651    rows.truncate(limit);
6652    Ok((rows, truncated))
6653}
6654
6655fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
6656    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
6657
6658    for row in rows {
6659        match grouped.get(&row.package_rowid) {
6660            Some(existing)
6661                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
6662                    != Ordering::Less => {}
6663            _ => {
6664                grouped.insert(row.package_rowid, row);
6665            }
6666        }
6667    }
6668
6669    grouped.into_values().collect()
6670}
6671
6672fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
6673    let has_hash = table_has_column(connection, "manifest", "hash")?;
6674    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
6675    let sql = format!(
6676        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
6677         FROM manifest \
6678         JOIN versions ON manifest.version = versions.rowid \
6679         JOIN channels ON manifest.channel = channels.rowid \
6680         WHERE manifest.id = ?1"
6681    );
6682    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
6683        Ok(V1VersionRow {
6684            version: row_string(row, 0)?,
6685            channel: row_string(row, 1)?,
6686            pathpart_id: row_i64(row, 2)?,
6687            manifest_hash: row_opt_hex_string(row, 3)?,
6688        })
6689    })
6690}
6691
6692fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
6693    let mut parts = Vec::new();
6694    let mut current = Some(pathpart_id);
6695
6696    while let Some(id) = current {
6697        let (parent, pathpart) = query_optional_value(
6698            connection,
6699            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
6700            vec![SqlValue::Integer(id)],
6701            |row| {
6702                Ok((
6703                    row_opt_string(row, 0)?
6704                        .and_then(|value| value.parse::<i64>().ok())
6705                        .filter(|value| *value != 0),
6706                    row_string(row, 1)?,
6707                ))
6708            },
6709        )?
6710        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
6711        parts.push(pathpart);
6712        current = parent;
6713    }
6714
6715    parts.reverse();
6716    Ok(parts.join("/"))
6717}
6718
6719fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
6720    format!(
6721        "packages/{}/{}/versionData.mszyml",
6722        package_id,
6723        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
6724    )
6725}
6726
6727fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
6728    let message = format!("{error:#}").to_ascii_lowercase();
6729    message.contains("no such table") && message.contains("packages")
6730}
6731
6732fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
6733    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
6734        row_string(row, 1)
6735    })? {
6736        if value.eq_ignore_ascii_case(column) {
6737            return Ok(true);
6738        }
6739    }
6740
6741    Ok(false)
6742}
6743
6744fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
6745    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
6746    let id_column = if v2 { "id" } else { "ids.id" };
6747    let name_column = if v2 { "name" } else { "names.name" };
6748    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
6749    let exact_match = query.exact || semantics == SearchSemantics::Single;
6750    let mut params = Vec::new();
6751
6752    if let Some(value) = &query.id {
6753        return (
6754            single_field_condition(id_column, value, exact_match, &mut params),
6755            params,
6756        );
6757    }
6758    if let Some(value) = &query.name {
6759        return (
6760            single_field_condition(name_column, value, exact_match, &mut params),
6761            params,
6762        );
6763    }
6764    if let Some(value) = &query.moniker {
6765        return (
6766            single_field_condition(moniker_column, value, exact_match, &mut params),
6767            params,
6768        );
6769    }
6770    if let Some(value) = &query.tag {
6771        return (
6772            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
6773            params,
6774        );
6775    }
6776    if let Some(value) = &query.command {
6777        return (
6778            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
6779            params,
6780        );
6781    }
6782    if let Some(value) = &query.query {
6783        if exact_match {
6784            let conditions = [
6785                single_field_condition(id_column, value, true, &mut params),
6786                single_field_condition(name_column, value, true, &mut params),
6787                single_field_condition(moniker_column, value, true, &mut params),
6788            ];
6789            return (format!("({})", conditions.join(" OR ")), params);
6790        }
6791
6792        let mut conditions = vec![
6793            single_field_condition(id_column, value, false, &mut params),
6794            single_field_condition(name_column, value, false, &mut params),
6795            single_field_condition(moniker_column, value, false, &mut params),
6796        ];
6797        if semantics == SearchSemantics::Many {
6798            conditions.push(mapped_field_condition(
6799                v2,
6800                "tag",
6801                value,
6802                rowid_column,
6803                false,
6804                &mut params,
6805            ));
6806            conditions.push(mapped_field_condition(
6807                v2,
6808                "command",
6809                value,
6810                rowid_column,
6811                false,
6812                &mut params,
6813            ));
6814        }
6815        return (format!("({})", conditions.join(" OR ")), params);
6816    }
6817
6818    ("1 = 1".to_owned(), Vec::new())
6819}
6820
6821fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
6822    params.push(match_parameter(value, exact));
6823    format!("{column} LIKE ?{}", params.len())
6824}
6825
6826fn mapped_field_condition(
6827    v2: bool,
6828    value_name: &str,
6829    value: &str,
6830    rowid_column: &str,
6831    exact: bool,
6832    params: &mut Vec<String>,
6833) -> String {
6834    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
6835        (
6836            format!("{value_name}s2"),
6837            format!("{value_name}s2_map"),
6838            value_name.to_owned(),
6839            "package".to_owned(),
6840        )
6841    } else {
6842        (
6843            format!("{value_name}s"),
6844            format!("{value_name}s_map"),
6845            value_name.to_owned(),
6846            "manifest".to_owned(),
6847        )
6848    };
6849    params.push(match_parameter(value, exact));
6850    let parameter = params.len();
6851    // Use a non-correlated `rowid IN (...)` subquery rather than a correlated
6852    // `EXISTS (... WHERE map.owner = packages.rowid ...)`. The correlated form
6853    // re-scans the (un-indexed) map table once per candidate package row, which
6854    // is quadratic: on the real winget index it takes ~19s under SQLite and
6855    // effectively hangs under the Turso engine. The `IN` form evaluates the
6856    // owner-id set a single time, collapsing the search back to milliseconds.
6857    format!(
6858        "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
6859         FROM {map_table_name} JOIN {table_name} ON \
6860         {map_table_name}.{value_name} = {table_name}.rowid \
6861         WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
6862    )
6863}
6864
6865fn match_parameter(value: &str, exact: bool) -> String {
6866    if exact { value.to_owned() } else { format!("%{value}%") }
6867}
6868
6869fn build_rest_search_body(
6870    query: &PackageQuery,
6871    info: &RestInformation,
6872    semantics: SearchSemantics,
6873) -> Result<JsonValue> {
6874    let mut root = serde_json::Map::new();
6875    root.insert(
6876        "MaximumResults".to_owned(),
6877        JsonValue::from(source_fetch_results(query, semantics) as u64),
6878    );
6879    let exact_match = query.exact || semantics == SearchSemantics::Single;
6880
6881    if let Some(value) = &query.query {
6882        if semantics == SearchSemantics::Single {
6883            let mut filters = vec![
6884                rest_filter("PackageIdentifier", value, true),
6885                rest_filter("PackageName", value, true),
6886                rest_filter("Moniker", value, true),
6887            ];
6888            append_required_rest_filters(&mut filters, info);
6889            root.insert("Filters".to_owned(), JsonValue::Array(filters));
6890            return Ok(JsonValue::Object(root));
6891        }
6892
6893        root.insert(
6894            "Query".to_owned(),
6895            serde_json::json!({
6896                "KeyWord": value,
6897                "MatchType": if exact_match { "Exact" } else { "Substring" },
6898            }),
6899        );
6900    }
6901
6902    let mut filters = Vec::new();
6903    if let Some(value) = &query.id {
6904        filters.push(rest_filter("PackageIdentifier", value, exact_match));
6905    }
6906    if let Some(value) = &query.name {
6907        filters.push(rest_filter("PackageName", value, exact_match));
6908    }
6909    if let Some(value) = &query.moniker {
6910        filters.push(rest_filter("Moniker", value, exact_match));
6911    }
6912    if let Some(value) = &query.tag {
6913        filters.push(rest_filter("Tag", value, true));
6914    }
6915    if let Some(value) = &query.command {
6916        filters.push(rest_filter("Command", value, true));
6917    }
6918    append_required_rest_filters(&mut filters, info);
6919
6920    if !filters.is_empty() {
6921        root.insert("Filters".to_owned(), JsonValue::Array(filters));
6922    }
6923
6924    Ok(JsonValue::Object(root))
6925}
6926
6927fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
6928    if info
6929        .required_package_match_fields
6930        .iter()
6931        .any(|field| field.eq_ignore_ascii_case("Market"))
6932    {
6933        filters.push(rest_filter("Market", &default_market(), true));
6934    }
6935}
6936
6937fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
6938    serde_json::json!({
6939        "PackageMatchField": field,
6940        "RequestMatch": {
6941            "KeyWord": value,
6942            "MatchType": if exact { "Exact" } else { "Substring" },
6943        }
6944    })
6945}
6946
6947fn max_results(query: &PackageQuery) -> usize {
6948    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
6949}
6950
6951fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
6952    match semantics {
6953        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
6954        SearchSemantics::Single => max_results(query),
6955    }
6956}
6957
6958fn infer_preindexed_match_criteria_v2(
6959    connection: &Connection,
6960    row: &V2SearchRow,
6961    query: &PackageQuery,
6962    semantics: SearchSemantics,
6963) -> Result<Option<String>> {
6964    infer_match_criteria(
6965        &row.id,
6966        &row.name,
6967        row.moniker.as_deref(),
6968        query,
6969        semantics,
6970        |value| {
6971            find_mapped_value_v2(
6972                connection,
6973                "tags2",
6974                "tags2_map",
6975                "tag",
6976                row.package_rowid,
6977                value,
6978                query.exact,
6979            )
6980        },
6981        |value| {
6982            find_mapped_value_v2(
6983                connection,
6984                "commands2",
6985                "commands2_map",
6986                "command",
6987                row.package_rowid,
6988                value,
6989                query.exact,
6990            )
6991        },
6992    )
6993}
6994
6995fn infer_preindexed_match_criteria_v1(
6996    connection: &Connection,
6997    row: &V1SearchRow,
6998    query: &PackageQuery,
6999    semantics: SearchSemantics,
7000) -> Result<Option<String>> {
7001    infer_match_criteria(
7002        &row.id,
7003        &row.name,
7004        row.moniker.as_deref(),
7005        query,
7006        semantics,
7007        |value| {
7008            find_mapped_value_v1(
7009                connection,
7010                "tags",
7011                "tags_map",
7012                "tag",
7013                row.manifest_rowid,
7014                value,
7015                query.exact,
7016            )
7017        },
7018        |value| {
7019            find_mapped_value_v1(
7020                connection,
7021                "commands",
7022                "commands_map",
7023                "command",
7024                row.manifest_rowid,
7025                value,
7026                query.exact,
7027            )
7028        },
7029    )
7030}
7031
7032fn infer_match_criteria<FTag, FCommand>(
7033    id: &str,
7034    name: &str,
7035    moniker: Option<&str>,
7036    query: &PackageQuery,
7037    semantics: SearchSemantics,
7038    find_tag: FTag,
7039    find_command: FCommand,
7040) -> Result<Option<String>>
7041where
7042    FTag: FnOnce(&str) -> Result<Option<String>>,
7043    FCommand: FnOnce(&str) -> Result<Option<String>>,
7044{
7045    if let Some(value) = &query.tag {
7046        return Ok(Some(format_match_criteria("Tag", value)));
7047    }
7048    if let Some(value) = &query.command {
7049        return Ok(Some(format_match_criteria("Command", value)));
7050    }
7051    if let Some(value) = &query.moniker {
7052        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
7053    }
7054    if semantics == SearchSemantics::Single {
7055        return Ok(None);
7056    }
7057    if let Some(value) = &query.query {
7058        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
7059            return Ok(None);
7060        }
7061        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
7062            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
7063        }
7064        if let Some(tag) = find_tag(value)? {
7065            return Ok(Some(format_match_criteria("Tag", &tag)));
7066        }
7067        if let Some(command) = find_command(value)? {
7068            return Ok(Some(format_match_criteria("Command", &command)));
7069        }
7070    }
7071    Ok(None)
7072}
7073
7074fn find_mapped_value_v2(
7075    connection: &Connection,
7076    table_name: &str,
7077    map_table_name: &str,
7078    value_name: &str,
7079    package_rowid: i64,
7080    query: &str,
7081    exact: bool,
7082) -> Result<Option<String>> {
7083    let sql = format!(
7084        "SELECT {table_name}.{value_name} FROM {map_table_name} \
7085         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
7086         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
7087    );
7088    let values = query_rows(
7089        connection,
7090        &sql,
7091        vec![
7092            SqlValue::Integer(package_rowid),
7093            SqlValue::Text(match_parameter(query, exact)),
7094        ],
7095        |row| row_string(row, 0),
7096    )?;
7097    Ok(select_best_text_match(values, query, exact))
7098}
7099
7100fn find_mapped_value_v1(
7101    connection: &Connection,
7102    table_name: &str,
7103    map_table_name: &str,
7104    value_name: &str,
7105    manifest_rowid: i64,
7106    query: &str,
7107    exact: bool,
7108) -> Result<Option<String>> {
7109    let sql = format!(
7110        "SELECT {table_name}.{value_name} FROM {map_table_name} \
7111         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
7112         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
7113    );
7114    let values = query_rows(
7115        connection,
7116        &sql,
7117        vec![
7118            SqlValue::Integer(manifest_rowid),
7119            SqlValue::Text(match_parameter(query, exact)),
7120        ],
7121        |row| row_string(row, 0),
7122    )?;
7123    Ok(select_best_text_match(values, query, exact))
7124}
7125
7126fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
7127    if let Some(value) = &query.tag {
7128        return Some(format_match_criteria("Tag", value));
7129    }
7130    if let Some(value) = &query.command {
7131        return Some(format_match_criteria("Command", value));
7132    }
7133    if let Some(value) = &query.moniker {
7134        return Some(format_match_criteria("Moniker", value));
7135    }
7136    if semantics == SearchSemantics::Single {
7137        return None;
7138    }
7139    let value = query.query.as_deref()?;
7140    if json_string(item, "PackageIdentifier")
7141        .as_deref()
7142        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
7143        || json_string(item, "PackageName")
7144            .as_deref()
7145            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
7146    {
7147        return None;
7148    }
7149    json_string(item, "Moniker")
7150        .filter(|candidate| matches_text(candidate, value, query.exact))
7151        .map(|candidate| format_match_criteria("Moniker", &candidate))
7152}
7153
7154fn format_match_criteria(field: &str, value: &str) -> String {
7155    format!("{field}: {value}")
7156}
7157
7158fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
7159    if exact {
7160        candidate.eq_ignore_ascii_case(query)
7161    } else {
7162        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
7163    }
7164}
7165
7166fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
7167    let mut score = 0;
7168
7169    if let Some(value) = &query.query {
7170        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
7171        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
7172        if let Some(moniker) = candidate.moniker.as_deref() {
7173            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
7174        }
7175    }
7176
7177    if let Some(value) = &query.id {
7178        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
7179    }
7180    if let Some(value) = &query.name {
7181        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
7182    }
7183    if let Some(value) = &query.moniker
7184        && let Some(moniker) = candidate.moniker.as_deref()
7185    {
7186        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
7187    }
7188    if let Some(value) = &query.tag
7189        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
7190    {
7191        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
7192    }
7193    if let Some(value) = &query.command
7194        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
7195    {
7196        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
7197    }
7198
7199    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
7200        score += 5;
7201    }
7202    if search_match_has_unknown_version(candidate) {
7203        score = score.saturating_sub(10);
7204    }
7205
7206    score
7207}
7208
7209fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
7210    candidate
7211        .version
7212        .as_deref()
7213        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
7214}
7215
7216/// Builds the YAML manifest emitted next to the installer by `winget download`.
7217///
7218/// `documents` is the merged singleton JSON the source provided; we narrow the
7219/// `Installers` array down to just the resolved installer so the file describes
7220/// exactly the binary that was written, mirroring native winget behavior. The
7221/// typed `manifest` overrides identity fields (id, version, name, channel) on
7222/// the JSON because callers like `manifest_for_match` correct those after the
7223/// initial YAML parse.
7224fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
7225    let mut document = match documents {
7226        JsonValue::Object(map) => map.clone(),
7227        _ => serde_json::Map::new(),
7228    };
7229
7230    document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
7231    document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
7232    document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
7233    if !manifest.channel.is_empty() {
7234        document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
7235    } else {
7236        document.remove("Channel");
7237    }
7238
7239    // Replace the multi-installer array with the single resolved installer so
7240    // the emitted manifest describes the file the user actually downloaded.
7241    let mut single_installer = serde_json::Map::new();
7242    if let Some(arch) = installer.architecture.as_ref() {
7243        single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
7244    }
7245    if let Some(installer_type) = installer.installer_type.as_ref() {
7246        single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
7247    }
7248    if let Some(url) = installer.url.as_ref() {
7249        single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
7250    }
7251    if let Some(sha) = installer.sha256.as_ref() {
7252        single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
7253    }
7254    if let Some(scope) = installer.scope.as_ref() {
7255        single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
7256    }
7257    if let Some(locale) = installer.locale.as_ref() {
7258        single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
7259    }
7260    if let Some(product_code) = installer.product_code.as_ref() {
7261        single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
7262    }
7263    if let Some(release_date) = installer.release_date.as_ref() {
7264        single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
7265    }
7266
7267    document.insert(
7268        "Installers".to_owned(),
7269        JsonValue::Array(vec![JsonValue::Object(single_installer)]),
7270    );
7271    document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
7272
7273    let value = JsonValue::Object(document);
7274    serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
7275}
7276
7277/// Computes the file name `winget download` uses for the YAML manifest
7278/// emitted next to the installer:
7279/// `{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml`.
7280/// Mirrors `GetInstallerDownloadOnlyFileName` in winget-cli's DownloadFlow.
7281fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
7282    let mut filename = derive_download_filename_stem(manifest, installer);
7283    filename.push_str(".yaml");
7284    filename
7285}
7286
7287fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
7288    let mut stem = sanitize_filename_component(&manifest.name);
7289    if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
7290        stem.push('_');
7291        stem.push_str(&sanitize_filename_component(&manifest.version));
7292    }
7293    if let Some(scope) = installer.scope.as_deref() {
7294        stem.push('_');
7295        stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
7296    }
7297    let architecture = installer.architecture.as_deref().unwrap_or("neutral");
7298    stem.push('_');
7299    stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
7300
7301    let installer_type = installer.installer_type.as_deref().unwrap_or("");
7302    if !installer_type.is_empty() {
7303        stem.push('_');
7304        stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
7305    }
7306    if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
7307        stem.push('_');
7308        stem.push_str(&sanitize_filename_component(locale));
7309    }
7310
7311    stem
7312}
7313
7314fn capitalize_first(value: &str) -> String {
7315    let mut chars = value.chars();
7316    match chars.next() {
7317        Some(first) => {
7318            let mut out = first.to_ascii_uppercase().to_string();
7319            out.push_str(&chars.as_str().to_ascii_lowercase());
7320            out
7321        }
7322        None => String::new(),
7323    }
7324}
7325
7326fn format_architecture(value: &str) -> String {
7327    match value.to_ascii_lowercase().as_str() {
7328        "x64" => "X64".to_owned(),
7329        "x86" => "X86".to_owned(),
7330        "arm" => "Arm".to_owned(),
7331        "arm64" => "Arm64".to_owned(),
7332        "neutral" => "Neutral".to_owned(),
7333        other => capitalize_first(other),
7334    }
7335}
7336
7337fn sanitize_filename_component(value: &str) -> String {
7338    value
7339        .chars()
7340        .map(|ch| match ch {
7341            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
7342            ch if ch.is_control() => '_',
7343            ch => ch,
7344        })
7345        .collect()
7346}
7347
7348fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
7349    if manifest_path.is_file() {
7350        return Ok(manifest_path.to_path_buf());
7351    }
7352
7353    if !manifest_path.is_dir() {
7354        bail!("Manifest path not found: {}", manifest_path.display());
7355    }
7356
7357    let candidate = fs::read_dir(manifest_path)?
7358        .filter_map(|entry| entry.ok())
7359        .map(|entry| entry.path())
7360        .filter(|path| {
7361            path.extension()
7362                .and_then(|value| value.to_str())
7363                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
7364        })
7365        .min();
7366
7367    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
7368}
7369
7370fn score_text_match(
7371    candidate: &str,
7372    query: &str,
7373    exact: bool,
7374    exact_score: usize,
7375    prefix_score: usize,
7376    substring_score: usize,
7377) -> usize {
7378    if candidate.eq_ignore_ascii_case(query) {
7379        return exact_score;
7380    }
7381    if exact {
7382        return 0;
7383    }
7384
7385    let candidate_lower = candidate.to_ascii_lowercase();
7386    let query_lower = query.to_ascii_lowercase();
7387    if candidate_lower.starts_with(&query_lower) {
7388        prefix_score
7389    } else if candidate_lower.contains(&query_lower) {
7390        substring_score
7391    } else {
7392        0
7393    }
7394}
7395
7396fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
7397where
7398    I: IntoIterator<Item = String>,
7399{
7400    values
7401        .into_iter()
7402        .filter_map(|candidate| {
7403            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
7404            (score > 0).then_some((score, candidate))
7405        })
7406        .max_by(|(left_score, left_value), (right_score, right_value)| {
7407            left_score
7408                .cmp(right_score)
7409                .then_with(|| right_value.len().cmp(&left_value.len()))
7410                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
7411        })
7412        .map(|(_, candidate)| candidate)
7413}
7414
7415fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
7416    let (field, value) = criteria.split_once(": ")?;
7417    Some((field, value))
7418}
7419
7420fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
7421    let versions = item
7422        .get("Versions")
7423        .and_then(JsonValue::as_array)
7424        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
7425    let mut result = Vec::new();
7426    for version in versions {
7427        let value = json_string(version, "PackageVersion")
7428            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
7429        let channel = json_string(version, "Channel").unwrap_or_default();
7430        result.push(VersionKey {
7431            version: value,
7432            channel,
7433        });
7434    }
7435    Ok(result)
7436}
7437
7438fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
7439    let mut merged = YamlMapping::new();
7440    let mut documents = Vec::new();
7441    for document in serde_yaml::Deserializer::from_slice(bytes) {
7442        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
7443        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
7444        if let Some(mapping) = value.as_mapping() {
7445            merge_yaml_mapping(&mut merged, mapping);
7446        }
7447    }
7448
7449    let id = yaml_string_from_root(&merged, "PackageIdentifier")
7450        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
7451    let version =
7452        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
7453    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
7454
7455    let installers = parse_yaml_installers(&merged);
7456    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
7457    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7458
7459    Ok((
7460        Manifest {
7461            id,
7462            name,
7463            version,
7464            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
7465            publisher: yaml_localized_string(&merged, "Publisher"),
7466            description: yaml_localized_string(&merged, "Description")
7467                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
7468            moniker: yaml_string_from_root(&merged, "Moniker"),
7469            package_url: yaml_localized_string(&merged, "PackageUrl"),
7470            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
7471            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
7472            license: yaml_localized_string(&merged, "License"),
7473            license_url: yaml_localized_string(&merged, "LicenseUrl"),
7474            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
7475            author: yaml_localized_string(&merged, "Author"),
7476            copyright: yaml_localized_string(&merged, "Copyright"),
7477            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
7478            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
7479            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
7480            tags: yaml_string_list(&merged, "Tags"),
7481            agreements: yaml_agreement_list(&merged),
7482            package_dependencies: yaml_package_dependencies(&merged),
7483            documentation: yaml_documentation_list(&merged),
7484            icons: yaml_icon_list(&merged),
7485            installers,
7486            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7487        },
7488        collapse_structured_document(&JsonValue::Array(documents)),
7489    ))
7490}
7491
7492fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
7493    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
7494}
7495
7496fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
7497    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
7498    let data = root
7499        .get("Data")
7500        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
7501    let versions = data
7502        .get("Versions")
7503        .and_then(JsonValue::as_array)
7504        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
7505    let selected = versions
7506        .iter()
7507        .find(|item| {
7508            json_string(item, "PackageVersion").as_deref() == Some(version)
7509                && json_string(item, "Channel").unwrap_or_default() == channel
7510        })
7511        .or_else(|| versions.first())
7512        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
7513
7514    let default_locale = selected
7515        .get("DefaultLocale")
7516        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
7517    let package_default_locale = data.get("DefaultLocale");
7518    let name = json_string(default_locale, "PackageName")
7519        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
7520    let installer_switch_defaults = json_installer_switches(selected);
7521    let top_platforms = json_string_list(selected, "Platform");
7522    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
7523    let top_installer_type = json_string(selected, "InstallerType");
7524    let top_nested_installer_type = json_string(selected, "NestedInstallerType");
7525    let top_nested_installer_files = json_nested_installer_files(selected);
7526
7527    let installers = selected
7528        .get("Installers")
7529        .and_then(JsonValue::as_array)
7530        .map(|items| {
7531            items
7532                .iter()
7533                .map(|item| Installer {
7534                    platforms: {
7535                        let platforms = json_string_list(item, "Platform");
7536                        if platforms.is_empty() {
7537                            top_platforms.clone()
7538                        } else {
7539                            platforms
7540                        }
7541                    },
7542                    minimum_os_version: json_string(item, "MinimumOSVersion")
7543                        .or_else(|| top_minimum_os_version.clone()),
7544                    architecture: json_string(item, "Architecture"),
7545                    installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
7546                    nested_installer_type: json_string(item, "NestedInstallerType")
7547                        .or_else(|| top_nested_installer_type.clone()),
7548                    nested_installer_files: {
7549                        let files = json_nested_installer_files(item);
7550                        if files.is_empty() {
7551                            top_nested_installer_files.clone()
7552                        } else {
7553                            files
7554                        }
7555                    },
7556                    url: json_string(item, "InstallerUrl"),
7557                    sha256: json_string(item, "InstallerSha256"),
7558                    product_code: json_string(item, "ProductCode"),
7559                    locale: json_string(item, "InstallerLocale"),
7560                    scope: json_string(item, "Scope"),
7561                    release_date: json_string(item, "ReleaseDate"),
7562                    package_family_name: json_string(item, "PackageFamilyName"),
7563                    upgrade_code: json_string(item, "UpgradeCode"),
7564                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
7565                    commands: json_string_list(item, "Commands"),
7566                    package_dependencies: json_package_dependencies(item),
7567                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
7568                })
7569                .collect::<Vec<_>>()
7570        })
7571        .unwrap_or_default();
7572    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
7573    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7574    let mut icons = json_icon_list(default_locale);
7575    if icons.is_empty() {
7576        icons = package_default_locale.map(json_icon_list).unwrap_or_default();
7577    }
7578
7579    let manifest = Manifest {
7580        id: package_id.to_owned(),
7581        name,
7582        version: version.to_owned(),
7583        channel: channel.to_owned(),
7584        publisher: json_string(default_locale, "Publisher"),
7585        description: json_string(default_locale, "Description")
7586            .or_else(|| json_string(default_locale, "ShortDescription")),
7587        moniker: json_string(default_locale, "Moniker"),
7588        package_url: json_string(default_locale, "PackageUrl"),
7589        publisher_url: json_string(default_locale, "PublisherUrl"),
7590        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
7591        license: json_string(default_locale, "License"),
7592        license_url: json_string(default_locale, "LicenseUrl"),
7593        privacy_url: json_string(default_locale, "PrivacyUrl"),
7594        author: json_string(default_locale, "Author"),
7595        copyright: json_string(default_locale, "Copyright"),
7596        copyright_url: json_string(default_locale, "CopyrightUrl"),
7597        release_notes: json_string(default_locale, "ReleaseNotes"),
7598        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
7599        tags: json_string_list(default_locale, "Tags"),
7600        agreements: json_agreement_list(default_locale),
7601        package_dependencies: json_package_dependencies(selected),
7602        documentation: json_documentation_list(default_locale),
7603        icons,
7604        installers,
7605        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7606    };
7607
7608    Ok((
7609        manifest,
7610        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
7611    ))
7612}
7613
7614fn collapse_structured_document(document: &JsonValue) -> JsonValue {
7615    match document {
7616        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
7617            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
7618        }
7619        JsonValue::Array(documents) => merge_manifest_documents(documents),
7620        JsonValue::Object(_) if is_merged_manifest_document(document) => {
7621            merge_manifest_documents(&split_merged_manifest_document(document))
7622        }
7623        _ => document.clone(),
7624    }
7625}
7626
7627fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
7628    documents.iter().map(collapse_structured_document).collect()
7629}
7630
7631fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
7632    let Some(root) = document.as_object_mut() else {
7633        return document;
7634    };
7635
7636    root.insert(
7637        "IconUrl".to_owned(),
7638        manifest
7639            .icon_url()
7640            .map(|url| JsonValue::String(url.to_owned()))
7641            .unwrap_or(JsonValue::Null),
7642    );
7643    root.insert(
7644        "Icon".to_owned(),
7645        manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
7646    );
7647    root.entry("Icons".to_owned())
7648        .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
7649
7650    document
7651}
7652
7653fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
7654    serde_json::to_value(icon).expect("package icon serialization cannot fail")
7655}
7656
7657fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
7658    let version = documents.iter().find(|document| {
7659        document
7660            .get("ManifestType")
7661            .and_then(JsonValue::as_str)
7662            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
7663    });
7664    let default_locale = documents.iter().find(|document| {
7665        document
7666            .get("ManifestType")
7667            .and_then(JsonValue::as_str)
7668            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
7669    });
7670    let installer = documents.iter().find(|document| {
7671        document
7672            .get("ManifestType")
7673            .and_then(JsonValue::as_str)
7674            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
7675    });
7676
7677    let mut singleton = JsonMap::new();
7678    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
7679    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
7680    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
7681
7682    if !singleton.contains_key("PackageLocale")
7683        && let Some(package_locale) = default_locale
7684            .and_then(|document| json_string(document, "PackageLocale"))
7685            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
7686    {
7687        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7688    }
7689
7690    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
7691    singleton.insert(
7692        "ManifestVersion".to_owned(),
7693        JsonValue::String(
7694            installer
7695                .and_then(|document| json_string(document, "ManifestVersion"))
7696                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
7697                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
7698                .unwrap_or_else(|| "1.10.0".to_owned()),
7699        ),
7700    );
7701    singleton.remove("DefaultLocale");
7702
7703    JsonValue::Object(singleton)
7704}
7705
7706fn is_merged_manifest_document(document: &JsonValue) -> bool {
7707    document
7708        .get("ManifestType")
7709        .and_then(JsonValue::as_str)
7710        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
7711}
7712
7713fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
7714    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
7715    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
7716    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7717    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
7718
7719    let version_document = serde_json::json!({
7720        "PackageIdentifier": package_identifier,
7721        "PackageVersion": package_version,
7722        "DefaultLocale": package_locale,
7723        "ManifestType": "version",
7724        "ManifestVersion": manifest_version,
7725    });
7726
7727    let mut default_locale_document = project_manifest_document(
7728        merged,
7729        &[
7730            "PackageIdentifier",
7731            "PackageVersion",
7732            "PackageLocale",
7733            "Publisher",
7734            "PublisherUrl",
7735            "PublisherSupportUrl",
7736            "PrivacyUrl",
7737            "Author",
7738            "PackageName",
7739            "PackageUrl",
7740            "License",
7741            "LicenseUrl",
7742            "Copyright",
7743            "CopyrightUrl",
7744            "ShortDescription",
7745            "Description",
7746            "Moniker",
7747            "Tags",
7748            "Agreements",
7749            "ReleaseNotes",
7750            "ReleaseNotesUrl",
7751            "PurchaseUrl",
7752            "InstallationNotes",
7753            "Documentations",
7754            "Icons",
7755        ],
7756    );
7757    default_locale_document.insert(
7758        "PackageIdentifier".to_owned(),
7759        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7760    );
7761    default_locale_document.insert(
7762        "PackageVersion".to_owned(),
7763        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7764    );
7765    default_locale_document.insert(
7766        "PackageLocale".to_owned(),
7767        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
7768    );
7769    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7770    default_locale_document.insert(
7771        "ManifestVersion".to_owned(),
7772        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7773    );
7774
7775    let mut installer_document = project_manifest_document(
7776        merged,
7777        &[
7778            "PackageIdentifier",
7779            "PackageVersion",
7780            "Channel",
7781            "InstallerLocale",
7782            "Platform",
7783            "MinimumOSVersion",
7784            "InstallerType",
7785            "NestedInstallerType",
7786            "NestedInstallerFiles",
7787            "Scope",
7788            "InstallModes",
7789            "InstallerSwitches",
7790            "InstallerSuccessCodes",
7791            "ExpectedReturnCodes",
7792            "UpgradeBehavior",
7793            "Commands",
7794            "Protocols",
7795            "FileExtensions",
7796            "Dependencies",
7797            "PackageFamilyName",
7798            "ProductCode",
7799            "Capabilities",
7800            "RestrictedCapabilities",
7801            "Markets",
7802            "InstallerAbortsTerminal",
7803            "ReleaseDate",
7804            "InstallLocationRequired",
7805            "RequireExplicitUpgrade",
7806            "DisplayInstallWarnings",
7807            "UnsupportedOSArchitectures",
7808            "UnsupportedArguments",
7809            "AppsAndFeaturesEntries",
7810            "ElevationRequirement",
7811            "InstallationMetadata",
7812            "DownloadCommandProhibited",
7813            "RepairBehavior",
7814            "ArchiveBinariesDependOnPath",
7815            "Authentication",
7816            "Installers",
7817        ],
7818    );
7819    installer_document.insert(
7820        "PackageIdentifier".to_owned(),
7821        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7822    );
7823    installer_document.insert(
7824        "PackageVersion".to_owned(),
7825        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7826    );
7827    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7828    installer_document.insert(
7829        "ManifestVersion".to_owned(),
7830        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7831    );
7832
7833    vec![
7834        version_document,
7835        JsonValue::Object(default_locale_document),
7836        JsonValue::Object(installer_document),
7837    ]
7838}
7839
7840fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
7841    let mut result = JsonMap::new();
7842    if let Some(source) = source.as_object() {
7843        for key in keys {
7844            if let Some(value) = source.get(*key) {
7845                result.insert((*key).to_owned(), value.clone());
7846            }
7847        }
7848    }
7849
7850    result
7851}
7852
7853fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
7854    if let Some(source) = source.and_then(JsonValue::as_object) {
7855        for key in keys {
7856            if let Some(value) = source.get(*key) {
7857                target.insert((*key).to_owned(), value.clone());
7858            }
7859        }
7860    }
7861}
7862
7863fn copy_all_manifest_keys_except(
7864    target: &mut JsonMap<String, JsonValue>,
7865    source: Option<&JsonValue>,
7866    excluded_keys: &[&str],
7867) {
7868    if let Some(source) = source.and_then(JsonValue::as_object) {
7869        for (key, value) in source {
7870            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
7871                target.insert(key.clone(), value.clone());
7872            }
7873        }
7874    }
7875}
7876
7877fn build_rest_manifest_documents(
7878    data: &JsonValue,
7879    installer_source: &JsonValue,
7880    default_locale: &JsonValue,
7881    package_id: &str,
7882    version: &str,
7883    channel: &str,
7884) -> JsonValue {
7885    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
7886    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7887    let manifest_version = json_string(installer_source, "ManifestVersion")
7888        .or_else(|| json_string(default_locale, "ManifestVersion"))
7889        .or_else(|| json_string(data, "ManifestVersion"))
7890        .unwrap_or_else(|| "1.10.0".to_owned());
7891
7892    let mut version_document = JsonMap::new();
7893    version_document.insert(
7894        "PackageIdentifier".to_owned(),
7895        JsonValue::String(package_identifier.clone()),
7896    );
7897    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7898    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
7899    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
7900    version_document.insert(
7901        "ManifestVersion".to_owned(),
7902        JsonValue::String(manifest_version.clone()),
7903    );
7904
7905    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
7906    default_locale_document.insert(
7907        "PackageIdentifier".to_owned(),
7908        JsonValue::String(package_identifier.clone()),
7909    );
7910    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7911    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7912    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7913    default_locale_document.insert(
7914        "ManifestVersion".to_owned(),
7915        JsonValue::String(manifest_version.clone()),
7916    );
7917
7918    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
7919    installer_document.remove("DefaultLocale");
7920    installer_document.remove("Locales");
7921    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
7922    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7923    if !channel.is_empty() {
7924        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
7925    }
7926    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7927    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
7928
7929    let documents = vec![
7930        JsonValue::Object(version_document),
7931        JsonValue::Object(default_locale_document),
7932        JsonValue::Object(installer_document),
7933    ];
7934
7935    merge_manifest_documents(&documents)
7936}
7937
7938fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
7939    let base = installer_defaults(root);
7940    let base_switches = yaml_installer_switches(root);
7941    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
7942        let installers = items
7943            .iter()
7944            .filter_map(YamlValue::as_mapping)
7945            .map(|item| {
7946                let mut merged = base.clone();
7947                merge_yaml_mapping(&mut merged, item);
7948                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
7949                installer_from_yaml(&merged, switches)
7950            })
7951            .collect::<Vec<_>>();
7952        if !installers.is_empty() {
7953            return installers;
7954        }
7955    }
7956
7957    if base.is_empty() {
7958        Vec::new()
7959    } else {
7960        vec![installer_from_yaml(&base, base_switches)]
7961    }
7962}
7963
7964fn installer_defaults(root: &YamlMapping) -> YamlMapping {
7965    let keys = [
7966        "Architecture",
7967        "InstallerType",
7968        "NestedInstallerType",
7969        "NestedInstallerFiles",
7970        "InstallerUrl",
7971        "InstallerSha256",
7972        "ProductCode",
7973        "InstallerLocale",
7974        "Platform",
7975        "MinimumOSVersion",
7976        "Scope",
7977        "ReleaseDate",
7978        "PackageFamilyName",
7979        "UpgradeCode",
7980        "Commands",
7981    ];
7982    let mut defaults = YamlMapping::new();
7983    for key in keys {
7984        if let Some(value) = root.get(YamlValue::from(key)) {
7985            defaults.insert(YamlValue::from(key), value.clone());
7986        }
7987    }
7988    defaults
7989}
7990
7991fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
7992    Installer {
7993        architecture: yaml_string(root, "Architecture"),
7994        installer_type: yaml_string(root, "InstallerType"),
7995        nested_installer_type: yaml_string(root, "NestedInstallerType"),
7996        nested_installer_files: yaml_nested_installer_files(root),
7997        url: yaml_string(root, "InstallerUrl"),
7998        sha256: yaml_string(root, "InstallerSha256"),
7999        product_code: yaml_string(root, "ProductCode"),
8000        locale: yaml_string(root, "InstallerLocale"),
8001        scope: yaml_string(root, "Scope"),
8002        release_date: yaml_string(root, "ReleaseDate"),
8003        package_family_name: yaml_string(root, "PackageFamilyName"),
8004        upgrade_code: yaml_string(root, "UpgradeCode"),
8005        platforms: yaml_string_list(root, "Platform"),
8006        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
8007        switches,
8008        commands: yaml_string_list(root, "Commands"),
8009        package_dependencies: yaml_package_dependencies(root),
8010        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
8011    }
8012}
8013
8014/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
8015/// the legacy `True`/`False` variants the winget catalog mixes in.
8016fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
8017    let Some(value) = root.get(YamlValue::from(key)) else {
8018        return false;
8019    };
8020    match value {
8021        YamlValue::Bool(b) => *b,
8022        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
8023        _ => false,
8024    }
8025}
8026
8027fn json_bool(value: &JsonValue, key: &str) -> bool {
8028    match value.get(key) {
8029        Some(JsonValue::Bool(b)) => *b,
8030        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
8031        _ => false,
8032    }
8033}
8034
8035fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
8036    let mapping = root
8037        .get(YamlValue::from("InstallerSwitches"))
8038        .or_else(|| root.get(YamlValue::from("Switches")))
8039        .and_then(YamlValue::as_mapping);
8040    yaml_installer_switches_from_mapping(mapping)
8041}
8042
8043fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
8044    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
8045
8046    InstallerSwitches {
8047        silent: string("Silent"),
8048        silent_with_progress: string("SilentWithProgress"),
8049        interactive: string("Interactive"),
8050        custom: string("Custom"),
8051        log: string("Log"),
8052        install_location: string("InstallLocation"),
8053    }
8054}
8055
8056fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
8057    yaml_string(root, key).or_else(|| {
8058        root.get(YamlValue::from("DefaultLocale"))
8059            .and_then(YamlValue::as_mapping)
8060            .and_then(|mapping| yaml_string(mapping, key))
8061    })
8062}
8063
8064fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
8065    yaml_string(root, key)
8066}
8067
8068fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
8069    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
8070}
8071
8072fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
8073    match value {
8074        YamlValue::Null => None,
8075        YamlValue::Bool(value) => Some(value.to_string()),
8076        YamlValue::Number(value) => Some(value.to_string()),
8077        YamlValue::String(value) => Some(value.clone()),
8078        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
8079        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
8080    }
8081}
8082
8083fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
8084    root.get(YamlValue::from(key))
8085        .and_then(YamlValue::as_sequence)
8086        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
8087        .unwrap_or_default()
8088}
8089
8090fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
8091    root.get(YamlValue::from("Documentations"))
8092        .and_then(YamlValue::as_sequence)
8093        .map(|items| {
8094            items
8095                .iter()
8096                .filter_map(YamlValue::as_mapping)
8097                .filter_map(|item| {
8098                    let url = yaml_string(item, "DocumentUrl")?;
8099                    Some(Documentation {
8100                        label: yaml_string(item, "DocumentLabel"),
8101                        url,
8102                    })
8103                })
8104                .collect()
8105        })
8106        .unwrap_or_default()
8107}
8108
8109fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
8110    root.get(YamlValue::from("Icons"))
8111        .and_then(YamlValue::as_sequence)
8112        .map(|items| {
8113            items
8114                .iter()
8115                .filter_map(YamlValue::as_mapping)
8116                .filter_map(|item| {
8117                    let icon = PackageIcon {
8118                        icon_url: yaml_string(item, "IconUrl"),
8119                        icon_file_type: yaml_string(item, "IconFileType"),
8120                        icon_resolution: yaml_string(item, "IconResolution"),
8121                        icon_theme: yaml_string(item, "IconTheme"),
8122                        icon_sha256: yaml_string(item, "IconSha256"),
8123                    };
8124                    (!icon.is_empty()).then_some(icon)
8125                })
8126                .collect()
8127        })
8128        .unwrap_or_default()
8129}
8130
8131fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
8132    root.get(YamlValue::from("Agreements"))
8133        .and_then(YamlValue::as_sequence)
8134        .map(|items| {
8135            items
8136                .iter()
8137                .filter_map(YamlValue::as_mapping)
8138                .map(|item| PackageAgreement {
8139                    label: yaml_string(item, "AgreementLabel"),
8140                    text: yaml_string(item, "Agreement"),
8141                    url: yaml_string(item, "AgreementUrl"),
8142                })
8143                .collect()
8144        })
8145        .unwrap_or_default()
8146}
8147
8148fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
8149    root.get(YamlValue::from("Dependencies"))
8150        .and_then(YamlValue::as_mapping)
8151        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
8152        .and_then(YamlValue::as_sequence)
8153        .map(|items| {
8154            items
8155                .iter()
8156                .filter_map(YamlValue::as_mapping)
8157                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
8158                .collect()
8159        })
8160        .unwrap_or_default()
8161}
8162
8163fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
8164    root.get(YamlValue::from("NestedInstallerFiles"))
8165        .and_then(YamlValue::as_sequence)
8166        .map(|items| {
8167            items
8168                .iter()
8169                .filter_map(YamlValue::as_mapping)
8170                .filter_map(|item| {
8171                    let relative = yaml_string(item, "RelativeFilePath")?;
8172                    Some(NestedInstallerFile {
8173                        relative_file_path: relative,
8174                        portable_command_alias: yaml_string(item, "PortableCommandAlias"),
8175                    })
8176                })
8177                .collect()
8178        })
8179        .unwrap_or_default()
8180}
8181
8182fn json_string(value: &JsonValue, key: &str) -> Option<String> {
8183    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
8184}
8185
8186fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
8187    value
8188        .get(key)
8189        .and_then(JsonValue::as_array)
8190        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
8191        .unwrap_or_default()
8192}
8193
8194fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
8195    value
8196        .get("Documentations")
8197        .and_then(JsonValue::as_array)
8198        .map(|items| {
8199            items
8200                .iter()
8201                .filter_map(|item| {
8202                    let url = json_string(item, "DocumentUrl")?;
8203                    Some(Documentation {
8204                        label: json_string(item, "DocumentLabel"),
8205                        url,
8206                    })
8207                })
8208                .collect()
8209        })
8210        .unwrap_or_default()
8211}
8212
8213fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
8214    value
8215        .get("Icons")
8216        .and_then(JsonValue::as_array)
8217        .map(|items| {
8218            items
8219                .iter()
8220                .filter_map(|item| {
8221                    let icon = PackageIcon {
8222                        icon_url: json_string(item, "IconUrl"),
8223                        icon_file_type: json_string(item, "IconFileType"),
8224                        icon_resolution: json_string(item, "IconResolution"),
8225                        icon_theme: json_string(item, "IconTheme"),
8226                        icon_sha256: json_string(item, "IconSha256"),
8227                    };
8228                    (!icon.is_empty()).then_some(icon)
8229                })
8230                .collect()
8231        })
8232        .unwrap_or_default()
8233}
8234
8235fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
8236    value
8237        .get("Agreements")
8238        .and_then(JsonValue::as_array)
8239        .map(|items| {
8240            items
8241                .iter()
8242                .map(|item| PackageAgreement {
8243                    label: json_string(item, "AgreementLabel"),
8244                    text: json_string(item, "Agreement"),
8245                    url: json_string(item, "AgreementUrl"),
8246                })
8247                .collect()
8248        })
8249        .unwrap_or_default()
8250}
8251
8252fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
8253    value
8254        .get("Dependencies")
8255        .and_then(JsonValue::as_object)
8256        .and_then(|deps| deps.get("PackageDependencies"))
8257        .and_then(JsonValue::as_array)
8258        .map(|items| {
8259            items
8260                .iter()
8261                .filter_map(|item| json_string(item, "PackageIdentifier"))
8262                .collect()
8263        })
8264        .unwrap_or_default()
8265}
8266
8267fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
8268    value
8269        .get("NestedInstallerFiles")
8270        .and_then(JsonValue::as_array)
8271        .map(|items| {
8272            items
8273                .iter()
8274                .filter_map(|item| {
8275                    let relative = json_string(item, "RelativeFilePath")?;
8276                    Some(NestedInstallerFile {
8277                        relative_file_path: relative,
8278                        portable_command_alias: json_string(item, "PortableCommandAlias"),
8279                    })
8280                })
8281                .collect()
8282        })
8283        .unwrap_or_default()
8284}
8285
8286fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
8287    let switches = value
8288        .get("InstallerSwitches")
8289        .or_else(|| value.get("Switches"))
8290        .and_then(JsonValue::as_object);
8291
8292    let string = |key: &str| {
8293        switches
8294            .and_then(|switches| switches.get(key))
8295            .and_then(JsonValue::as_str)
8296            .map(str::to_owned)
8297    };
8298
8299    InstallerSwitches {
8300        silent: string("Silent"),
8301        silent_with_progress: string("SilentWithProgress"),
8302        interactive: string("Interactive"),
8303        custom: string("Custom"),
8304        log: string("Log"),
8305        install_location: string("InstallLocation"),
8306    }
8307}
8308
8309fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
8310    for (key, value) in source {
8311        target.insert(key.clone(), value.clone());
8312    }
8313}
8314
8315fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
8316    let reader = Cursor::new(bytes.to_vec());
8317    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
8318    let mut file = archive
8319        .by_name(member_name)
8320        .with_context(|| format!("zip payload missing {member_name}"))?;
8321    let mut output = Vec::new();
8322    file.read_to_end(&mut output)
8323        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
8324    Ok(output)
8325}
8326
8327fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
8328    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
8329        return Ok(output);
8330    }
8331
8332    if !bytes.starts_with(b"CK")
8333        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
8334    {
8335        return decompress_mszyml_payload(&bytes[offset + 2..]);
8336    }
8337
8338    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
8339}
8340
8341fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
8342    let mut decoder = flate2::read::DeflateDecoder::new(payload);
8343    let mut output = String::new();
8344    decoder.read_to_string(&mut output)?;
8345    Ok(output)
8346}
8347
8348fn cache_control_max_age(response: &Response) -> u64 {
8349    response
8350        .headers()
8351        .get(reqwest::header::CACHE_CONTROL)
8352        .and_then(|value| value.to_str().ok())
8353        .and_then(|header| {
8354            header.split(',').find_map(|part| {
8355                let trimmed = part.trim();
8356                trimmed
8357                    .strip_prefix("max-age=")
8358                    .and_then(|value| value.parse::<u64>().ok())
8359            })
8360        })
8361        .unwrap_or(60)
8362}
8363
8364fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
8365    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
8366        let (major, minor) = major_minor(candidate);
8367        server_versions.iter().any(|version| {
8368            let (server_major, server_minor) = major_minor(version);
8369            server_major == major && server_minor == minor
8370        })
8371    })
8372}
8373
8374fn major_minor(version: &str) -> (String, String) {
8375    let mut parts = version.split('.');
8376    (
8377        parts.next().unwrap_or_default().to_owned(),
8378        parts.next().unwrap_or_default().to_owned(),
8379    )
8380}
8381
8382fn default_market() -> String {
8383    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
8384}
8385
8386fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
8387    let requested_locale = query.locale.as_deref();
8388    let requested_architecture = query.installer_architecture.as_deref();
8389    let requested_type = query.installer_type.as_deref();
8390    let requested_scope = query.install_scope.as_deref();
8391    let requested_platform = query.platform.as_deref();
8392    let requested_os_version = query.os_version.as_deref();
8393    let system_architecture = current_architecture();
8394    let system_platform = current_platform();
8395    let current_os_version = current_os_version();
8396
8397    installers
8398        .iter()
8399        .enumerate()
8400        .filter(|(_, installer)| {
8401            installer_matches_requested(
8402                installer,
8403                requested_type,
8404                requested_scope,
8405                requested_platform,
8406                requested_os_version,
8407                system_platform,
8408                current_os_version.as_deref(),
8409            )
8410        })
8411        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
8412        .max_by_key(|(index, installer)| {
8413            (
8414                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
8415                std::cmp::Reverse(*index),
8416            )
8417        })
8418        .map(|(_, installer)| installer)
8419        .cloned()
8420}
8421
8422fn installer_matches_requested(
8423    installer: &Installer,
8424    requested_type: Option<&str>,
8425    requested_scope: Option<&str>,
8426    requested_platform: Option<&str>,
8427    requested_os_version: Option<&str>,
8428    system_platform: Option<&str>,
8429    current_os_version: Option<&str>,
8430) -> bool {
8431    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
8432        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
8433        && installer_matches_platform(installer, requested_platform, system_platform)
8434        && installer_matches_os_version(installer, requested_os_version, current_os_version)
8435}
8436
8437fn installer_matches_platform(
8438    installer: &Installer,
8439    requested_platform: Option<&str>,
8440    system_platform: Option<&str>,
8441) -> bool {
8442    if installer.platforms.is_empty() {
8443        return true;
8444    }
8445
8446    let Some(effective_platform) = requested_platform.or(system_platform) else {
8447        return true;
8448    };
8449
8450    installer
8451        .platforms
8452        .iter()
8453        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
8454}
8455
8456fn installer_matches_os_version(
8457    installer: &Installer,
8458    requested_os_version: Option<&str>,
8459    current_os_version: Option<&str>,
8460) -> bool {
8461    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
8462        return true;
8463    };
8464
8465    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
8466        return true;
8467    };
8468
8469    match (
8470        parse_dotted_version(minimum_os_version),
8471        parse_dotted_version(actual_os_version),
8472    ) {
8473        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
8474        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
8475    }
8476}
8477
8478fn installer_matches_architecture(
8479    installer: &Installer,
8480    requested_architecture: Option<&str>,
8481    system_architecture: &str,
8482) -> bool {
8483    let Some(architecture) = installer.architecture.as_deref() else {
8484        return true;
8485    };
8486
8487    let architecture = architecture.to_ascii_lowercase();
8488    if let Some(requested) = requested_architecture {
8489        return architecture.eq_ignore_ascii_case(requested);
8490    }
8491
8492    preferred_architectures(system_architecture)
8493        .iter()
8494        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
8495}
8496
8497fn installer_rank(
8498    installer: &Installer,
8499    requested_locale: Option<&str>,
8500    requested_architecture: Option<&str>,
8501    system_architecture: &str,
8502) -> (i32, i32, i32) {
8503    let architecture_rank = architecture_rank(
8504        installer.architecture.as_deref(),
8505        requested_architecture.unwrap_or(system_architecture),
8506        requested_architecture.is_some(),
8507        system_architecture,
8508    );
8509    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
8510    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
8511    (architecture_rank, locale_rank, command_rank)
8512}
8513
8514fn architecture_rank(
8515    installer_architecture: Option<&str>,
8516    preferred_architecture: &str,
8517    strict: bool,
8518    system_architecture: &str,
8519) -> i32 {
8520    let Some(value) = installer_architecture else {
8521        return 0;
8522    };
8523    if value.eq_ignore_ascii_case(preferred_architecture) {
8524        return 5;
8525    }
8526    if value.eq_ignore_ascii_case("neutral") {
8527        return 4;
8528    }
8529    if strict {
8530        return -1;
8531    }
8532
8533    preferred_architectures(system_architecture)
8534        .iter()
8535        .rev()
8536        .position(|candidate| value.eq_ignore_ascii_case(candidate))
8537        .and_then(|index| i32::try_from(index).ok())
8538        .and_then(|index| index.checked_add(1))
8539        .unwrap_or(-1)
8540}
8541
8542/// True when at least one installer in `manifest.installers` targets an
8543/// architecture the host can run. winget hides upgrades whose latest
8544/// version publishes no compatible installer.
8545fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
8546    if manifest.installers.is_empty() {
8547        return true;
8548    }
8549    let allowed = preferred_architectures(current_architecture());
8550    manifest.installers.iter().any(|installer| {
8551        let arch = installer.architecture.as_deref().unwrap_or("neutral");
8552        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
8553    })
8554}
8555
8556fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
8557    match system_architecture {
8558        "arm64" => &["arm64", "neutral", "x64", "x86"],
8559        "x64" => &["x64", "neutral", "x86"],
8560        "x86" => &["x86", "neutral"],
8561        _ => &["neutral"],
8562    }
8563}
8564
8565fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
8566    match (installer_locale, requested_locale) {
8567        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
8568        (Some(installer), Some(requested))
8569            if installer
8570                .split('-')
8571                .next()
8572                .zip(requested.split('-').next())
8573                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
8574        {
8575            2
8576        }
8577        (None, Some(_)) => 1,
8578        (Some(_), None) => 1,
8579        (None, None) => 0,
8580        _ => -1,
8581    }
8582}
8583
8584fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
8585    match requested {
8586        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
8587        None => true,
8588    }
8589}
8590
8591#[cfg(windows)]
8592fn current_architecture() -> &'static str {
8593    use std::sync::OnceLock;
8594    static CELL: OnceLock<&'static str> = OnceLock::new();
8595    CELL.get_or_init(|| {
8596        // IsWow64Process2's `native_machine` is the OS architecture even
8597        // when the process is running under emulation (x64 binary on
8598        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
8599        // mis-report the host arch in that case.
8600        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
8601        let mut process_machine: u16 = 0;
8602        let mut native_machine: u16 = 0;
8603        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
8604        // both out-pointers reference local stack u16s that outlive the call.
8605        let handle = unsafe { GetCurrentProcess() };
8606        // SAFETY: handle is valid per above; pointers are valid.
8607        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
8608        if ok != 0 {
8609            match native_machine {
8610                0xAA64 => return "arm64",
8611                0x8664 => return "x64",
8612                0x014C => return "x86",
8613                _ => {}
8614            }
8615        }
8616        compiled_architecture()
8617    })
8618}
8619
8620#[cfg(not(windows))]
8621fn current_architecture() -> &'static str {
8622    compiled_architecture()
8623}
8624
8625fn compiled_architecture() -> &'static str {
8626    match std::env::consts::ARCH {
8627        "x86_64" => "x64",
8628        "x86" => "x86",
8629        "aarch64" => "arm64",
8630        _ => "neutral",
8631    }
8632}
8633
8634fn current_platform() -> Option<&'static str> {
8635    if cfg!(windows) { Some("Windows.Desktop") } else { None }
8636}
8637
8638#[cfg(windows)]
8639fn current_os_version() -> Option<String> {
8640    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8641    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
8642    let build: String = key.get_value("CurrentBuildNumber").ok()?;
8643    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
8644    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
8645    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
8646    Some(format!("{major}.{minor}.{build}.{ubr}"))
8647}
8648
8649#[cfg(not(windows))]
8650fn current_os_version() -> Option<String> {
8651    None
8652}
8653
8654fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
8655    let mut parsed = Vec::new();
8656    for component in version.split('.') {
8657        if component.is_empty() {
8658            continue;
8659        }
8660        parsed.push(component.parse().ok()?);
8661    }
8662
8663    if parsed.is_empty() { None } else { Some(parsed) }
8664}
8665
8666fn select_v1_version(
8667    versions: &[V1VersionRow],
8668    requested_version: Option<&str>,
8669    requested_channel: Option<&str>,
8670    package_id: &str,
8671    source_name: &str,
8672) -> Result<V1VersionRow> {
8673    let mut ordered = versions.to_vec();
8674    ordered.sort_by(|left, right| {
8675        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8676    });
8677
8678    if let Some(version) = requested_version {
8679        let channel = requested_channel.unwrap_or_default();
8680        return ordered
8681            .iter()
8682            .find(|candidate| {
8683                candidate.version.eq_ignore_ascii_case(version)
8684                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8685            })
8686            .cloned()
8687            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8688    }
8689
8690    if let Some(channel) = requested_channel {
8691        return ordered
8692            .iter()
8693            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8694            .cloned()
8695            .ok_or_else(|| {
8696                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8697            });
8698    }
8699
8700    ordered
8701        .into_iter()
8702        .next()
8703        .ok_or_else(|| anyhow!("package had no versions"))
8704}
8705
8706fn select_v2_version(
8707    versions: &[PackageVersionDataEntry],
8708    requested_version: Option<&str>,
8709    requested_channel: Option<&str>,
8710    package_id: &str,
8711    source_name: &str,
8712) -> Result<PackageVersionDataEntry> {
8713    let mut ordered = versions.to_vec();
8714    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
8715
8716    if let Some(channel) = requested_channel {
8717        bail!(
8718            "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."
8719        );
8720    }
8721
8722    if let Some(version) = requested_version {
8723        return ordered
8724            .iter()
8725            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
8726            .cloned()
8727            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8728    }
8729
8730    ordered
8731        .into_iter()
8732        .next()
8733        .ok_or_else(|| anyhow!("package had no V2 versions"))
8734}
8735
8736fn missing_package_version(
8737    package_id: &str,
8738    source_name: &str,
8739    requested_version: &str,
8740    requested_channel: Option<&str>,
8741) -> anyhow::Error {
8742    anyhow!(MissingPackageVersionError {
8743        package_id: package_id.to_owned(),
8744        source_name: source_name.to_owned(),
8745        requested_version: requested_version.to_owned(),
8746        requested_channel: requested_channel.map(str::to_owned),
8747    })
8748}
8749
8750fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
8751    if let Some(refresh_error) = refresh.error.as_deref() {
8752        let missing_error = error.to_string();
8753        return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
8754    }
8755
8756    Err(error)
8757}
8758
8759fn select_rest_version<'a>(
8760    versions: &'a [VersionKey],
8761    requested_version: Option<&str>,
8762    requested_channel: Option<&str>,
8763    package_id: &str,
8764    source_name: &str,
8765) -> Result<&'a VersionKey> {
8766    if let Some(version) = requested_version {
8767        let channel = requested_channel.unwrap_or_default();
8768        return versions
8769            .iter()
8770            .find(|candidate| {
8771                candidate.version.eq_ignore_ascii_case(version)
8772                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8773            })
8774            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8775    }
8776
8777    if let Some(channel) = requested_channel {
8778        return versions
8779            .iter()
8780            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8781            .ok_or_else(|| {
8782                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8783            });
8784    }
8785
8786    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
8787}
8788
8789fn sort_versions_desc(versions: &mut [VersionKey]) {
8790    versions.sort_by(|left, right| {
8791        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8792    });
8793}
8794
8795fn compare_version_and_channel(
8796    left_version: &str,
8797    left_channel: &str,
8798    right_version: &str,
8799    right_channel: &str,
8800) -> Ordering {
8801    compare_version(left_version, right_version).then_with(|| {
8802        left_channel
8803            .to_ascii_lowercase()
8804            .cmp(&right_channel.to_ascii_lowercase())
8805    })
8806}
8807
8808fn compare_version(left: &str, right: &str) -> Ordering {
8809    let left_less_than = left.trim().strip_prefix('<').map(str::trim);
8810    let right_less_than = right.trim().strip_prefix('<').map(str::trim);
8811    match (left_less_than, right_less_than) {
8812        (Some(left_value), Some(right_value)) => return compare_version(left_value, right_value),
8813        (Some(left_value), None) if compare_version(left_value, right) == Ordering::Equal => return Ordering::Less,
8814        (None, Some(right_value)) if compare_version(left, right_value) == Ordering::Equal => return Ordering::Greater,
8815        _ => {}
8816    }
8817
8818    let left_parts = tokenize_version(left);
8819    let right_parts = tokenize_version(right);
8820    let max_len = left_parts.len().max(right_parts.len());
8821
8822    for index in 0..max_len {
8823        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
8824        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
8825        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
8826
8827        let ordering = if let Some((left_number, right_number)) = numeric {
8828            left_number.cmp(&right_number)
8829        } else {
8830            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
8831        };
8832
8833        if ordering != Ordering::Equal {
8834            return ordering;
8835        }
8836    }
8837
8838    Ordering::Equal
8839}
8840
8841fn tokenize_version(value: &str) -> Vec<String> {
8842    let mut parts = Vec::new();
8843    let mut current = String::new();
8844    let mut current_is_digit = None;
8845
8846    for ch in value.chars() {
8847        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
8848            if !current.is_empty() {
8849                parts.push(std::mem::take(&mut current));
8850            }
8851            current_is_digit = None;
8852            continue;
8853        }
8854
8855        let is_digit = ch.is_ascii_digit();
8856        match current_is_digit {
8857            Some(previous) if previous != is_digit => {
8858                parts.push(std::mem::take(&mut current));
8859                current.push(ch);
8860                current_is_digit = Some(is_digit);
8861            }
8862            Some(_) => current.push(ch),
8863            None => {
8864                current.push(ch);
8865                current_is_digit = Some(is_digit);
8866            }
8867        }
8868    }
8869
8870    if !current.is_empty() {
8871        parts.push(current);
8872    }
8873
8874    parts
8875}
8876
8877fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
8878    if !hash_matches(Some(expected_hash), bytes) {
8879        bail!("downloaded file hash did not match expected SHA256");
8880    }
8881    Ok(())
8882}
8883
8884#[cfg(test)]
8885fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
8886    let Some(expected_hash) = expected_hash else {
8887        return Ok(());
8888    };
8889    if ignore_security_hash {
8890        return Ok(());
8891    }
8892
8893    if !hash_matches(Some(expected_hash), bytes) {
8894        let actual = sha256_hex(bytes);
8895        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
8896    }
8897    Ok(())
8898}
8899
8900fn verify_installer_hash_hex(expected_hash: Option<&str>, actual_hash: &str, ignore_security_hash: bool) -> Result<()> {
8901    let Some(expected_hash) = expected_hash else {
8902        return Ok(());
8903    };
8904    if ignore_security_hash {
8905        return Ok(());
8906    }
8907
8908    if !actual_hash.eq_ignore_ascii_case(expected_hash) {
8909        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual_hash}");
8910    }
8911    Ok(())
8912}
8913
8914const INSTALLER_DOWNLOAD_ATTEMPTS: usize = 3;
8915const INSTALLER_DOWNLOAD_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(30);
8916const INSTALLER_DOWNLOAD_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(60);
8917const INSTALLER_DOWNLOAD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30 * 60);
8918const INSTALLER_DOWNLOAD_METADATA_INTERVAL: u64 = 4 * 1024 * 1024;
8919
8920#[derive(Debug, Clone, Serialize, Deserialize)]
8921struct InstallerDownloadMetadata {
8922    url: String,
8923    etag: Option<String>,
8924    last_modified: Option<String>,
8925    total_length: Option<u64>,
8926    offset: u64,
8927}
8928
8929#[derive(Debug)]
8930struct DownloadAttempt {
8931    total_length: Option<u64>,
8932}
8933
8934fn download_installer_to_file(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8935    let runtime = tokio::runtime::Builder::new_current_thread()
8936        .enable_all()
8937        .build()
8938        .context("failed to create installer download runtime")?;
8939    runtime.block_on(download_installer_to_file_async(user_agent, url, dest))
8940}
8941
8942async fn download_installer_to_file_async(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8943    let part_path = sidecar_path(dest, "part");
8944    let metadata_path = sidecar_path(dest, "part.json");
8945    let started_at = Instant::now();
8946    let mut last_error = None;
8947
8948    for _attempt in 1..=INSTALLER_DOWNLOAD_ATTEMPTS {
8949        if started_at.elapsed() >= INSTALLER_DOWNLOAD_TOTAL_TIMEOUT {
8950            break;
8951        }
8952
8953        let remaining = INSTALLER_DOWNLOAD_TOTAL_TIMEOUT
8954            .checked_sub(started_at.elapsed())
8955            .unwrap_or_else(|| StdDuration::from_secs(1));
8956        match try_download_installer_to_part(user_agent, url, &part_path, &metadata_path, remaining).await {
8957            Ok(()) => {
8958                let actual_hash = sha256_file_hex(&part_path)?;
8959                replace_file(&part_path, dest)?;
8960                let _ = fs::remove_file(&metadata_path);
8961                return Ok(actual_hash);
8962            }
8963            Err(error) => {
8964                last_error = Some(error);
8965            }
8966        }
8967    }
8968
8969    Err(last_error.unwrap_or_else(|| anyhow!("installer download timed out")))
8970}
8971
8972async fn try_download_installer_to_part(
8973    user_agent: &str,
8974    url: &str,
8975    part_path: &Path,
8976    metadata_path: &Path,
8977    remaining: StdDuration,
8978) -> Result<()> {
8979    let metadata = read_installer_download_metadata(metadata_path, part_path, url)?;
8980    let mut offset = metadata.as_ref().map_or(0, |metadata| metadata.offset);
8981    let mut request = reqwest::Client::builder()
8982        .user_agent(user_agent)
8983        .connect_timeout(INSTALLER_DOWNLOAD_CONNECT_TIMEOUT)
8984        .timeout(remaining)
8985        .build()
8986        .context("failed to build installer download client")?
8987        .get(url)
8988        .header(ACCEPT_ENCODING, "identity");
8989
8990    if offset > 0 {
8991        request = request.header(RANGE, format!("bytes={offset}-"));
8992        if let Some(if_range) = metadata.as_ref().and_then(installer_download_if_range) {
8993            request = request.header(IF_RANGE, if_range);
8994        }
8995    }
8996
8997    let mut response = request.send().await.context("failed to download installer")?;
8998    let headers = response.headers().clone();
8999    let status = response.status();
9000    let attempt = if status == StatusCode::PARTIAL_CONTENT && offset > 0 {
9001        parse_content_range(headers.get(CONTENT_RANGE), offset)?
9002    } else if status == StatusCode::OK {
9003        offset = 0;
9004        DownloadAttempt {
9005            total_length: content_length(&headers),
9006        }
9007    } else if status == StatusCode::RANGE_NOT_SATISFIABLE {
9008        let _ = fs::remove_file(part_path);
9009        let _ = fs::remove_file(metadata_path);
9010        bail!("installer partial download could not be resumed; retrying from the beginning");
9011    } else if status.is_success() {
9012        bail!("unexpected installer download HTTP status {status}");
9013    } else {
9014        bail!("Download failed: HTTP {status}");
9015    };
9016
9017    if offset == 0 {
9018        let _ = fs::remove_file(part_path);
9019    }
9020
9021    let mut file = if offset == 0 {
9022        fs::File::create(part_path).with_context(|| format!("failed to create {}", part_path.display()))?
9023    } else {
9024        OpenOptions::new()
9025            .append(true)
9026            .open(part_path)
9027            .with_context(|| format!("failed to open {}", part_path.display()))?
9028    };
9029
9030    let mut metadata = InstallerDownloadMetadata {
9031        url: url.to_owned(),
9032        etag: strong_etag(&headers),
9033        last_modified: header_string(&headers, LAST_MODIFIED),
9034        total_length: attempt.total_length,
9035        offset,
9036    };
9037    write_installer_download_metadata(metadata_path, &metadata)?;
9038
9039    let mut next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
9040    while let Some(chunk) = tokio::time::timeout(INSTALLER_DOWNLOAD_IDLE_TIMEOUT, response.chunk())
9041        .await
9042        .context("installer download stalled while waiting for data")?
9043        .context("failed to read installer response body")?
9044    {
9045        file.write_all(&chunk)
9046            .with_context(|| format!("failed to write {}", part_path.display()))?;
9047        offset += u64::try_from(chunk.len()).expect("chunk length fits in u64");
9048
9049        if offset >= next_metadata_offset {
9050            file.flush()
9051                .with_context(|| format!("failed to flush {}", part_path.display()))?;
9052            metadata.offset = offset;
9053            write_installer_download_metadata(metadata_path, &metadata)?;
9054            next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
9055        }
9056    }
9057
9058    file.flush()
9059        .with_context(|| format!("failed to flush {}", part_path.display()))?;
9060    file.sync_all()
9061        .with_context(|| format!("failed to sync {}", part_path.display()))?;
9062
9063    if let Some(total_length) = attempt.total_length
9064        && offset != total_length
9065    {
9066        bail!("installer download ended early: received {offset} of {total_length} bytes");
9067    }
9068
9069    metadata.offset = offset;
9070    metadata.total_length = attempt.total_length;
9071    write_installer_download_metadata(metadata_path, &metadata)?;
9072    Ok(())
9073}
9074
9075fn read_installer_download_metadata(
9076    metadata_path: &Path,
9077    part_path: &Path,
9078    url: &str,
9079) -> Result<Option<InstallerDownloadMetadata>> {
9080    let Some(mut metadata) = fs::read(metadata_path)
9081        .ok()
9082        .and_then(|bytes| serde_json::from_slice::<InstallerDownloadMetadata>(&bytes).ok())
9083    else {
9084        let _ = fs::remove_file(part_path);
9085        let _ = fs::remove_file(metadata_path);
9086        return Ok(None);
9087    };
9088
9089    if metadata.url != url || metadata.offset == 0 {
9090        let _ = fs::remove_file(part_path);
9091        let _ = fs::remove_file(metadata_path);
9092        return Ok(None);
9093    }
9094
9095    let part_len = fs::metadata(part_path).map(|metadata| metadata.len()).unwrap_or(0);
9096    metadata.offset = metadata.offset.min(part_len);
9097    if metadata.offset == 0 {
9098        let _ = fs::remove_file(part_path);
9099        let _ = fs::remove_file(metadata_path);
9100        return Ok(None);
9101    }
9102
9103    OpenOptions::new()
9104        .write(true)
9105        .open(part_path)
9106        .with_context(|| format!("failed to open {}", part_path.display()))?
9107        .set_len(metadata.offset)
9108        .with_context(|| format!("failed to truncate {}", part_path.display()))?;
9109
9110    Ok(Some(metadata))
9111}
9112
9113fn write_installer_download_metadata(path: &Path, metadata: &InstallerDownloadMetadata) -> Result<()> {
9114    let bytes = serde_json::to_vec(metadata).context("failed to serialize installer download metadata")?;
9115    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
9116}
9117
9118fn installer_download_if_range(metadata: &InstallerDownloadMetadata) -> Option<String> {
9119    metadata
9120        .etag
9121        .as_ref()
9122        .filter(|etag| !etag.starts_with("W/"))
9123        .cloned()
9124        .or_else(|| metadata.last_modified.clone())
9125}
9126
9127fn parse_content_range(value: Option<&HeaderValue>, expected_start: u64) -> Result<DownloadAttempt> {
9128    let value = value
9129        .and_then(|value| value.to_str().ok())
9130        .ok_or_else(|| anyhow!("partial installer response missing Content-Range"))?;
9131    let range = value
9132        .strip_prefix("bytes ")
9133        .ok_or_else(|| anyhow!("unsupported Content-Range value: {value}"))?;
9134    let (span, total) = range
9135        .split_once('/')
9136        .ok_or_else(|| anyhow!("invalid Content-Range value: {value}"))?;
9137    let (start, _) = span
9138        .split_once('-')
9139        .ok_or_else(|| anyhow!("invalid Content-Range byte span: {value}"))?;
9140    let start = start
9141        .parse::<u64>()
9142        .with_context(|| format!("invalid Content-Range start: {value}"))?;
9143    if start != expected_start {
9144        bail!("partial installer response started at {start}, expected {expected_start}");
9145    }
9146    let total_length = if total == "*" {
9147        None
9148    } else {
9149        Some(
9150            total
9151                .parse::<u64>()
9152                .with_context(|| format!("invalid Content-Range total: {value}"))?,
9153        )
9154    };
9155    Ok(DownloadAttempt { total_length })
9156}
9157
9158fn content_length(headers: &HeaderMap) -> Option<u64> {
9159    headers
9160        .get(CONTENT_LENGTH)
9161        .and_then(|value| value.to_str().ok())
9162        .and_then(|value| value.parse::<u64>().ok())
9163}
9164
9165fn strong_etag(headers: &HeaderMap) -> Option<String> {
9166    header_string(headers, ETAG).filter(|value| !value.starts_with("W/"))
9167}
9168
9169fn header_string(headers: &HeaderMap, name: reqwest::header::HeaderName) -> Option<String> {
9170    headers
9171        .get(name)
9172        .and_then(|value| value.to_str().ok())
9173        .map(str::to_owned)
9174}
9175
9176fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
9177    let mut value = path.as_os_str().to_os_string();
9178    value.push(".");
9179    value.push(suffix);
9180    PathBuf::from(value)
9181}
9182
9183fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
9184    expected_hash
9185        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
9186        .unwrap_or(true)
9187}
9188
9189fn sha256_hex(bytes: &[u8]) -> String {
9190    let digest = Sha256::digest(bytes);
9191    sha256_digest_hex(digest)
9192}
9193
9194fn sha256_file_hex(path: &Path) -> Result<String> {
9195    let mut file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
9196    let mut hasher = Sha256::new();
9197    let mut buffer = [0u8; 128 * 1024];
9198    loop {
9199        let read = file
9200            .read(&mut buffer)
9201            .with_context(|| format!("failed to read {}", path.display()))?;
9202        if read == 0 {
9203            break;
9204        }
9205        hasher.update(&buffer[..read]);
9206    }
9207    Ok(sha256_digest_hex(hasher.finalize()))
9208}
9209
9210fn sha256_digest_hex(digest: impl AsRef<[u8]>) -> String {
9211    let digest = digest.as_ref();
9212    let mut output = String::with_capacity(digest.len() * 2);
9213    for byte in digest {
9214        output.push_str(&format!("{byte:02X}"));
9215    }
9216    output
9217}
9218
9219fn pins_db_path(app_root: &Path) -> PathBuf {
9220    if uses_packaged_layout(app_root) {
9221        app_root.join("pinning.db")
9222    } else {
9223        app_root.join("pins.db")
9224    }
9225}
9226
9227fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
9228    let mut has_current_column = false;
9229    let mut has_legacy_column = false;
9230
9231    for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
9232        match column_name.as_str() {
9233            "type" => has_current_column = true,
9234            "pin_type" => has_legacy_column = true,
9235            _ => {}
9236        }
9237    }
9238
9239    Ok(if has_current_column {
9240        Some("type")
9241    } else if has_legacy_column {
9242        Some("pin_type")
9243    } else {
9244        None
9245    })
9246}
9247
9248fn decode_pin_type(pin_type_int: i64) -> PinType {
9249    match pin_type_int {
9250        4 => PinType::Blocking,
9251        3 => PinType::Gating,
9252        _ => PinType::Pinning,
9253    }
9254}
9255
9256#[cfg(windows)]
9257fn dispatch_installer(
9258    installer_path: &Path,
9259    installer_type: &str,
9260    request: &InstallRequest,
9261    manifest: &Manifest,
9262    installer: &Installer,
9263) -> Result<i32> {
9264    match installer_type {
9265        "msi" | "wix" if should_run_msi_direct(request, installer) => run_msi_direct(
9266            installer_path,
9267            &installer_direct_msi_arguments(installer_type, request, manifest, installer_path, installer),
9268        ),
9269        "msi" | "wix" => shell_execute_installer(
9270            Path::new("msiexec.exe"),
9271            &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9272            should_elevate_msi_shell_execute(installer),
9273        ),
9274        "msix" | "appx" => {
9275            use std::process::Command;
9276
9277            let mut cmd = Command::new("powershell");
9278            cmd.arg("-NoProfile")
9279                .arg("-Command")
9280                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
9281            let status = cmd.status().context("failed to run Add-AppxPackage")?;
9282            Ok(status.code().unwrap_or(-1))
9283        }
9284        "zip" => {
9285            // A `zip` at the top-level installer position is treated as portable when
9286            // `NestedInstallerType=portable` is specified — that's the shape every
9287            // portable in the winget catalog uses (e.g. BurntSushi.ripgrep.MSVC).
9288            // Anything else still falls through to the historical extract-to-Programs
9289            // behavior, which isn't really correct but at least doesn't regress.
9290            if installer
9291                .nested_installer_type
9292                .as_deref()
9293                .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9294            {
9295                return install_portable(installer_path, request, manifest, installer);
9296            }
9297            let target = dirs::data_local_dir()
9298                .unwrap_or_else(|| PathBuf::from("."))
9299                .join("Programs");
9300            fs::create_dir_all(&target)?;
9301            let file = fs::File::open(installer_path)?;
9302            let mut archive = ZipArchive::new(file)?;
9303            archive.extract(&target)?;
9304            Ok(0)
9305        }
9306        "portable" => install_portable(installer_path, request, manifest, installer),
9307        // exe, inno, nullsoft, burn, etc. are launched through ShellExecute so
9308        // Windows can honor installer manifests and legacy elevation detection.
9309        _ => shell_execute_installer(
9310            installer_path,
9311            &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9312            false,
9313        ),
9314    }
9315}
9316
9317#[cfg(windows)]
9318fn should_run_msi_direct(request: &InstallRequest, installer: &Installer) -> bool {
9319    request.mode == InstallerMode::Silent && !should_elevate_msi_shell_execute(installer)
9320}
9321
9322#[cfg(windows)]
9323fn should_elevate_msi_shell_execute(installer: &Installer) -> bool {
9324    installer
9325        .scope
9326        .as_deref()
9327        .is_some_and(|scope| scope.eq_ignore_ascii_case("machine"))
9328}
9329
9330#[cfg(windows)]
9331fn shell_execute_installer(installer_path: &Path, args: &[String], run_as: bool) -> Result<i32> {
9332    use std::ffi::OsStr;
9333
9334    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, WAIT_OBJECT_0};
9335    use windows_sys::Win32::System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject};
9336    use windows_sys::Win32::UI::Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW};
9337    use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
9338
9339    let file = wide_null(installer_path.as_os_str());
9340    let parameters = wide_null(OsStr::new(&join_windows_arguments(args)));
9341    let verb = run_as.then(|| wide_null(OsStr::new("runas")));
9342
9343    // SAFETY: SHELLEXECUTEINFOW is a plain Win32 data structure; zero initialization
9344    // is the documented starting point before setting cbSize and the fields we use.
9345    let mut exec_info = unsafe { std::mem::zeroed::<SHELLEXECUTEINFOW>() };
9346    exec_info.cbSize =
9347        u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).expect("SHELLEXECUTEINFOW size fits in u32");
9348    exec_info.fMask = SEE_MASK_NOCLOSEPROCESS;
9349    exec_info.lpFile = file.as_ptr();
9350    exec_info.lpParameters = parameters.as_ptr();
9351    if let Some(verb) = verb.as_ref() {
9352        exec_info.lpVerb = verb.as_ptr();
9353    }
9354    exec_info.nShow = SW_SHOW;
9355
9356    // SAFETY: exec_info points to a valid SHELLEXECUTEINFOW whose string pointers
9357    // reference live, nul-terminated UTF-16 buffers for the duration of the call.
9358    if unsafe { ShellExecuteExW(&mut exec_info) } == 0 {
9359        bail!(
9360            "failed to launch installer through ShellExecute (Win32 error {})",
9361            // SAFETY: GetLastError reads the calling thread's last-error value.
9362            unsafe { GetLastError() }
9363        );
9364    }
9365
9366    if exec_info.hProcess.is_null() {
9367        bail!("ShellExecute did not return an installer process handle");
9368    }
9369
9370    // SAFETY: ShellExecuteExW returned a non-null process handle with
9371    // SEE_MASK_NOCLOSEPROCESS, so waiting on it is valid.
9372    let wait_result = unsafe { WaitForSingleObject(exec_info.hProcess, INFINITE) };
9373    if wait_result != WAIT_OBJECT_0 {
9374        // SAFETY: hProcess is the owned process handle returned by ShellExecuteExW.
9375        let _ = unsafe { CloseHandle(exec_info.hProcess) };
9376        bail!("failed while waiting for installer process (Win32 wait result {wait_result})");
9377    }
9378
9379    let mut exit_code = 0u32;
9380    // SAFETY: hProcess is still open and valid, and exit_code points to writable memory.
9381    let got_exit_code = unsafe { GetExitCodeProcess(exec_info.hProcess, &mut exit_code) };
9382    // SAFETY: hProcess is the owned process handle returned by ShellExecuteExW.
9383    let _ = unsafe { CloseHandle(exec_info.hProcess) };
9384    if got_exit_code == 0 {
9385        // SAFETY: GetLastError reads the calling thread's last-error value.
9386        let error = unsafe { GetLastError() };
9387        bail!("failed to retrieve installer exit code (Win32 error {error})");
9388    }
9389
9390    Ok(exit_code.cast_signed())
9391}
9392
9393#[cfg(windows)]
9394fn wide_null(value: &std::ffi::OsStr) -> Vec<u16> {
9395    value.encode_wide().chain(std::iter::once(0)).collect()
9396}
9397
9398#[cfg(windows)]
9399fn run_msi_direct(installer_path: &Path, args: &[String]) -> Result<i32> {
9400    use std::ffi::OsStr;
9401    use std::ptr::{null, null_mut};
9402
9403    use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
9404        INSTALLUILEVEL_NONE, INSTALLUILEVEL_UACONLY, MsiEnableLogW, MsiInstallProductW, MsiSetInternalUI,
9405    };
9406
9407    let parsed = parse_msi_direct_arguments(args)?;
9408    let package_path = wide_null(installer_path.as_os_str());
9409    let properties = wide_null(OsStr::new(&parsed.properties));
9410    let property_ptr = if parsed.properties.is_empty() {
9411        null()
9412    } else {
9413        properties.as_ptr()
9414    };
9415    let log_path = parsed.log_path.as_ref().map(|path| wide_null(OsStr::new(path)));
9416
9417    if let Some(path) = log_path.as_ref() {
9418        // SAFETY: path is a live, nul-terminated UTF-16 buffer; mode and attributes
9419        // are parsed from MSI logging switches.
9420        let result = unsafe { MsiEnableLogW(parsed.log_mode, path.as_ptr(), parsed.log_attributes) };
9421        if result != 0 {
9422            bail!("failed to enable MSI logging (Win32 error {result})");
9423        }
9424    } else {
9425        // SAFETY: Passing a null file with mode 0 disables MSI logging.
9426        let _ = unsafe { MsiEnableLogW(0, null(), 0) };
9427    }
9428
9429    let ui_level = if parsed.ui_level == INSTALLUILEVEL_NONE as u32 {
9430        (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY) as u32
9431    } else {
9432        parsed.ui_level
9433    };
9434    // SAFETY: ui_level is composed from documented INSTALLUILEVEL flags; null HWND
9435    // matches the MsiSetInternalUI contract when no owner window is supplied.
9436    unsafe { MsiSetInternalUI(ui_level.cast_signed(), null_mut()) };
9437    // SAFETY: package_path and property_ptr are valid, nul-terminated UTF-16
9438    // strings (or null for no properties) for the duration of the call.
9439    let result = unsafe { MsiInstallProductW(package_path.as_ptr(), property_ptr) };
9440    Ok(result.cast_signed())
9441}
9442
9443#[cfg(any(windows, test))]
9444#[derive(Debug, PartialEq, Eq)]
9445struct MsiDirectArguments {
9446    ui_level: u32,
9447    properties: String,
9448    log_mode: u32,
9449    log_path: Option<String>,
9450    log_attributes: u32,
9451}
9452
9453#[cfg(any(windows, test))]
9454fn parse_msi_direct_arguments(args: &[String]) -> Result<MsiDirectArguments> {
9455    const INSTALLUILEVEL_DEFAULT: u32 = 1;
9456    const INSTALLUILEVEL_NONE: u32 = 2;
9457    const INSTALLUILEVEL_BASIC: u32 = 3;
9458    const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9459    const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9460    const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9461    const LOGMODE_DEFAULT: u32 = 1 | 2 | 4 | 16 | 128 | 256 | 512;
9462    const LOGMODE_ALL: u32 = LOGMODE_DEFAULT | 8 | 1024 | 2048;
9463    const LOGMODE_VERBOSE: u32 = 4096;
9464    const LOGMODE_EXTRADEBUG: u32 = 8192;
9465    const LOGATTR_APPEND: u32 = 1;
9466    const LOGATTR_FLUSH: u32 = 2;
9467
9468    let mut parsed = MsiDirectArguments {
9469        ui_level: INSTALLUILEVEL_DEFAULT,
9470        properties: String::new(),
9471        log_mode: 0,
9472        log_path: None,
9473        log_attributes: 0,
9474    };
9475
9476    let mut index = 0;
9477    while index < args.len() {
9478        let token = &args[index];
9479        if is_msi_switch(token) {
9480            let option = &token[1..];
9481            if option.eq_ignore_ascii_case("quiet") {
9482                parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9483            } else if option.eq_ignore_ascii_case("passive") {
9484                parsed.ui_level = INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL;
9485                append_msi_property(&mut parsed.properties, "REBOOTPROMPT=S");
9486            } else if option.eq_ignore_ascii_case("norestart") {
9487                append_msi_property(&mut parsed.properties, "REBOOT=ReallySuppress");
9488            } else if option.eq_ignore_ascii_case("forcerestart") {
9489                append_msi_property(&mut parsed.properties, "REBOOT=Force");
9490            } else if option.eq_ignore_ascii_case("promptrestart") {
9491                append_msi_property(&mut parsed.properties, "REBOOTPROMPT=\"\"");
9492            } else if option.eq_ignore_ascii_case("log") {
9493                index += 1;
9494                let path = args.get(index).ok_or_else(|| anyhow!("MSI /log requires a log path"))?;
9495                parsed.log_mode = LOGMODE_ALL;
9496                parsed.log_path = Some(path.clone());
9497            } else if option.len() == 1 && option.eq_ignore_ascii_case("q") {
9498                parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9499            } else if option.starts_with(['q', 'Q']) {
9500                parsed.ui_level = parse_msi_ui_level(&option[1..])?;
9501            } else if option.starts_with(['l', 'L']) {
9502                index += 1;
9503                let path = args
9504                    .get(index)
9505                    .ok_or_else(|| anyhow!("MSI logging switch requires a log path"))?;
9506                let (mode, attributes) = parse_msi_log_mode(
9507                    &option[1..],
9508                    LOGMODE_DEFAULT,
9509                    LOGMODE_ALL,
9510                    LOGMODE_VERBOSE,
9511                    LOGMODE_EXTRADEBUG,
9512                    LOGATTR_APPEND,
9513                    LOGATTR_FLUSH,
9514                )?;
9515                parsed.log_mode = mode;
9516                parsed.log_attributes = attributes;
9517                parsed.log_path = Some(path.clone());
9518            } else {
9519                bail!("unsupported MSI switch for direct MSI execution: {token}");
9520            }
9521        } else {
9522            append_msi_property(&mut parsed.properties, &format_msi_property(token)?);
9523        }
9524        index += 1;
9525    }
9526
9527    Ok(parsed)
9528}
9529
9530#[cfg(any(windows, test))]
9531fn is_msi_switch(token: &str) -> bool {
9532    token.starts_with('/') || token.starts_with('-')
9533}
9534
9535#[cfg(any(windows, test))]
9536fn append_msi_property(properties: &mut String, property: &str) {
9537    if !properties.is_empty() {
9538        properties.push(' ');
9539    }
9540    properties.push_str(property);
9541}
9542
9543#[cfg(any(windows, test))]
9544fn format_msi_property(token: &str) -> Result<String> {
9545    let Some((name, value)) = token.split_once('=') else {
9546        bail!("invalid MSI property argument: {token}");
9547    };
9548    if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
9549        bail!("invalid MSI property name: {name}");
9550    }
9551    if value.contains(char::is_whitespace) && !(value.starts_with('"') && value.ends_with('"')) {
9552        Ok(format!("{name}=\"{}\"", value.replace('"', "\"\"")))
9553    } else {
9554        Ok(token.to_owned())
9555    }
9556}
9557
9558#[cfg(any(windows, test))]
9559fn parse_msi_ui_level(modifier: &str) -> Result<u32> {
9560    const INSTALLUILEVEL_NONE: u32 = 2;
9561    const INSTALLUILEVEL_BASIC: u32 = 3;
9562    const INSTALLUILEVEL_REDUCED: u32 = 4;
9563    const INSTALLUILEVEL_FULL: u32 = 5;
9564    const INSTALLUILEVEL_ENDDIALOG: u32 = 0x80;
9565    const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9566    const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9567    const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9568
9569    let modifier = if modifier.is_empty() { "n" } else { modifier };
9570    let mut chars = modifier.chars();
9571    let Some(first) = chars.next() else {
9572        return Ok(INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY);
9573    };
9574    let mut ui_level = match first.to_ascii_lowercase() {
9575        'f' => INSTALLUILEVEL_FULL,
9576        'r' => INSTALLUILEVEL_REDUCED,
9577        'b' => INSTALLUILEVEL_BASIC,
9578        'n' => INSTALLUILEVEL_NONE,
9579        '+' => INSTALLUILEVEL_NONE | INSTALLUILEVEL_ENDDIALOG,
9580        _ => bail!("invalid MSI /q modifier: {modifier}"),
9581    };
9582
9583    for ch in chars {
9584        match ch {
9585            '+' => ui_level |= INSTALLUILEVEL_ENDDIALOG,
9586            '-' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_PROGRESSONLY,
9587            '!' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_HIDECANCEL,
9588            '-' | '!' => bail!("MSI /q modifier {ch} is only valid with basic UI"),
9589            _ => bail!("invalid MSI /q modifier: {modifier}"),
9590        }
9591    }
9592
9593    if ui_level & 0xF == INSTALLUILEVEL_NONE {
9594        ui_level |= INSTALLUILEVEL_UACONLY;
9595    }
9596    Ok(ui_level)
9597}
9598
9599#[cfg(any(windows, test))]
9600#[allow(clippy::too_many_arguments)]
9601fn parse_msi_log_mode(
9602    modifier: &str,
9603    default_mode: u32,
9604    all_mode: u32,
9605    verbose: u32,
9606    extra_debug: u32,
9607    append: u32,
9608    flush: u32,
9609) -> Result<(u32, u32)> {
9610    let mut mode = 0;
9611    let mut attributes = 0;
9612    for ch in modifier.chars() {
9613        match ch.to_ascii_lowercase() {
9614            '*' => mode |= all_mode,
9615            'm' => mode |= 1,
9616            'e' => mode |= 2,
9617            'w' => mode |= 4,
9618            'u' => mode |= 8,
9619            'i' => mode |= 16,
9620            'o' => mode |= 128,
9621            'a' => mode |= 256,
9622            'r' => mode |= 512,
9623            'p' => mode |= 1024,
9624            'c' => mode |= 2048,
9625            'v' => mode |= verbose,
9626            'x' => mode |= extra_debug,
9627            '+' => attributes |= append,
9628            '!' => attributes |= flush,
9629            _ => bail!("invalid MSI /l modifier: {modifier}"),
9630        }
9631    }
9632    if mode == 0 {
9633        mode = default_mode;
9634    }
9635    Ok((mode, attributes))
9636}
9637
9638#[cfg(not(windows))]
9639fn dispatch_installer(
9640    _installer_path: &Path,
9641    _installer_type: &str,
9642    _request: &InstallRequest,
9643    _manifest: &Manifest,
9644    _installer: &Installer,
9645) -> Result<i32> {
9646    bail!("Installing packages is only supported on Windows")
9647}
9648
9649#[cfg(test)]
9650fn installer_command_arguments(
9651    installer_type: &str,
9652    request: &InstallRequest,
9653    _manifest: &Manifest,
9654    installer_path: &Path,
9655    installer: &Installer,
9656) -> Vec<String> {
9657    installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9658}
9659
9660#[cfg(any(windows, test))]
9661fn installer_direct_msi_arguments(
9662    installer_type: &str,
9663    request: &InstallRequest,
9664    _manifest: &Manifest,
9665    installer_path: &Path,
9666    installer: &Installer,
9667) -> Vec<String> {
9668    installer_command_arguments_with_options(installer_type, request, installer_path, installer, false)
9669}
9670
9671#[cfg(any(windows, test))]
9672fn installer_shell_execute_arguments(
9673    installer_type: &str,
9674    request: &InstallRequest,
9675    _manifest: &Manifest,
9676    installer_path: &Path,
9677    installer: &Installer,
9678) -> Vec<String> {
9679    installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9680}
9681
9682#[cfg(any(windows, test))]
9683fn installer_command_arguments_with_options(
9684    installer_type: &str,
9685    request: &InstallRequest,
9686    installer_path: &Path,
9687    installer: &Installer,
9688    include_msi_install_target: bool,
9689) -> Vec<String> {
9690    if let Some(override_args) = request.override_args.as_deref() {
9691        return split_installer_switches(override_args);
9692    }
9693
9694    let mut args = Vec::new();
9695    match installer_type {
9696        "msi" | "wix" if include_msi_install_target => {
9697            args.push("/i".to_owned());
9698            args.push(installer_path.display().to_string());
9699        }
9700        "msix" | "appx" | "zip" => return Vec::new(),
9701        _ => {}
9702    }
9703
9704    let configured = match request.mode {
9705        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
9706        InstallerMode::SilentWithProgress => installer
9707            .switches
9708            .silent_with_progress
9709            .as_deref()
9710            .or(installer.switches.silent.as_deref()),
9711        InstallerMode::Silent => installer
9712            .switches
9713            .silent
9714            .as_deref()
9715            .or(installer.switches.silent_with_progress.as_deref()),
9716    };
9717
9718    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
9719        args.extend(split_installer_switches(value));
9720    } else {
9721        args.extend(default_installer_arguments(installer_type, request.mode));
9722    }
9723
9724    append_switches(
9725        &mut args,
9726        resolve_template_switch(
9727            installer.switches.log.as_deref(),
9728            default_log_switch(installer_type),
9729            request.log_path.as_ref().map(|path| path.display().to_string()),
9730            "<LOGPATH>",
9731        ),
9732    );
9733    append_switches(&mut args, installer.switches.custom.clone());
9734    append_switches(&mut args, request.custom.clone());
9735    append_switches(
9736        &mut args,
9737        resolve_template_switch(
9738            installer.switches.install_location.as_deref(),
9739            default_install_location_switch(installer_type),
9740            request.install_location.clone(),
9741            "<INSTALLPATH>",
9742        ),
9743    );
9744    args
9745}
9746
9747#[cfg(any(windows, test))]
9748fn join_windows_arguments(args: &[String]) -> String {
9749    args.iter()
9750        .map(|arg| quote_windows_argument(arg))
9751        .collect::<Vec<_>>()
9752        .join(" ")
9753}
9754
9755#[cfg(any(windows, test))]
9756fn quote_windows_argument(arg: &str) -> String {
9757    if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') {
9758        return arg.to_owned();
9759    }
9760
9761    let mut quoted = String::from("\"");
9762    let mut backslashes = 0usize;
9763    for ch in arg.chars() {
9764        match ch {
9765            '\\' => backslashes += 1,
9766            '"' => {
9767                quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
9768                quoted.push('"');
9769                backslashes = 0;
9770            }
9771            _ => {
9772                quoted.push_str(&"\\".repeat(backslashes));
9773                backslashes = 0;
9774                quoted.push(ch);
9775            }
9776        }
9777    }
9778    quoted.push_str(&"\\".repeat(backslashes * 2));
9779    quoted.push('"');
9780    quoted
9781}
9782
9783#[cfg(any(windows, test))]
9784fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
9785    match mode {
9786        InstallerMode::Interactive => Vec::new(),
9787        InstallerMode::SilentWithProgress => match installer_type {
9788            "inno" => vec![
9789                "/SP-".to_owned(),
9790                "/SILENT".to_owned(),
9791                "/SUPPRESSMSGBOXES".to_owned(),
9792                "/NORESTART".to_owned(),
9793            ],
9794            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
9795            "nullsoft" | "nsis" => vec!["/S".to_owned()],
9796            _ => vec!["/SILENT".to_owned()],
9797        },
9798        InstallerMode::Silent => match installer_type {
9799            "inno" => vec![
9800                "/SP-".to_owned(),
9801                "/VERYSILENT".to_owned(),
9802                "/SUPPRESSMSGBOXES".to_owned(),
9803                "/NORESTART".to_owned(),
9804            ],
9805            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
9806            "nullsoft" | "nsis" => vec!["/S".to_owned()],
9807            _ => vec!["/S".to_owned()],
9808        },
9809    }
9810}
9811
9812#[cfg(any(windows, test))]
9813fn default_log_switch(installer_type: &str) -> Option<&'static str> {
9814    match installer_type {
9815        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
9816        "inno" => Some("/LOG=\"<LOGPATH>\""),
9817        _ => None,
9818    }
9819}
9820
9821#[cfg(any(windows, test))]
9822fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
9823    match installer_type {
9824        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
9825        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
9826        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
9827        _ => None,
9828    }
9829}
9830
9831#[cfg(any(windows, test))]
9832fn resolve_template_switch(
9833    manifest_value: Option<&str>,
9834    fallback: Option<&str>,
9835    replacement: Option<String>,
9836    token: &str,
9837) -> Option<String> {
9838    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
9839    let replacement = replacement?;
9840    Some(template.replace(token, &replacement))
9841}
9842
9843#[cfg(any(windows, test))]
9844fn append_switches(args: &mut Vec<String>, value: Option<String>) {
9845    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
9846        args.extend(split_installer_switches(&value));
9847    }
9848}
9849
9850#[cfg(any(windows, test))]
9851fn split_installer_switches(value: &str) -> Vec<String> {
9852    let mut args = Vec::new();
9853    let mut current = String::new();
9854    let mut in_quotes = false;
9855
9856    for ch in value.chars() {
9857        match ch {
9858            '"' => in_quotes = !in_quotes,
9859            c if c.is_whitespace() && !in_quotes => {
9860                if !current.is_empty() {
9861                    args.push(std::mem::take(&mut current));
9862                }
9863            }
9864            _ => current.push(ch),
9865        }
9866    }
9867
9868    if !current.is_empty() {
9869        args.push(current);
9870    }
9871
9872    args
9873}
9874
9875#[cfg(windows)]
9876fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
9877    if request.purge && request.preserve {
9878        bail!("--purge and --preserve cannot be used together.");
9879    }
9880
9881    if installed
9882        .installer_category
9883        .as_deref()
9884        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9885    {
9886        return uninstall_portable(installed, request);
9887    }
9888
9889    if (request.purge || request.preserve) && !request.force {
9890        bail!("--purge and --preserve are currently only supported for portable packages.");
9891    }
9892
9893    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
9894        return Ok(exit_code);
9895    }
9896
9897    if let Some(exit_code) = try_uninstall_msix(installed)? {
9898        return Ok(exit_code);
9899    }
9900
9901    bail!(
9902        "No uninstall command found for installed package '{}' ({})",
9903        installed.name,
9904        installed.local_id
9905    );
9906}
9907
9908#[cfg(windows)]
9909fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9910    use std::os::windows::process::CommandExt;
9911    use std::process::Command;
9912
9913    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
9914    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9915
9916    let arp_paths = [
9917        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
9918        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
9919    ];
9920
9921    for root in [&hklm, &hkcu] {
9922        for arp_path in &arp_paths {
9923            if let Ok(key) = root.open_subkey(arp_path) {
9924                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
9925                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
9926                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
9927                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
9928                        if !registry_entry_matches_installed_package(
9929                            &subkey_name,
9930                            &display_name,
9931                            product_code.as_deref(),
9932                            installed,
9933                        ) {
9934                            continue;
9935                        }
9936
9937                        if let Some(exit_code) =
9938                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
9939                        {
9940                            return Ok(Some(exit_code));
9941                        }
9942
9943                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
9944                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
9945                            subkey.get_value("UninstallString").ok()
9946                        } else {
9947                            quiet_uninstall_cmd
9948                                .clone()
9949                                .or_else(|| subkey.get_value("UninstallString").ok())
9950                        }
9951                        .context("No uninstall command found in registry")?;
9952
9953                        if is_winget_uninstall_command(&uninstall_cmd)
9954                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
9955                        {
9956                            return Ok(Some(exit_code));
9957                        }
9958
9959                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
9960                        let uninstall_line = build_uninstall_command_with_mode(
9961                            &uninstall_cmd,
9962                            request.mode,
9963                            quiet_uninstall_cmd.is_some(),
9964                            log_path.as_deref(),
9965                        );
9966                        // `UninstallString` is a complete command line and may already contain
9967                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
9968                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
9969                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
9970                        // rejects with "is not recognized as an internal or external command".
9971                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
9972                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
9973                        // automatic quoting so we can feed cmd the literal command line.
9974                        let mut cmd = Command::new("cmd");
9975                        cmd.raw_arg("/S /C");
9976                        cmd.raw_arg(format!("\"{uninstall_line}\""));
9977                        let status = cmd.status().context("failed to run uninstaller")?;
9978                        return Ok(Some(status.code().unwrap_or(-1)));
9979                    }
9980                }
9981            }
9982        }
9983    }
9984
9985    Ok(None)
9986}
9987
9988#[cfg(windows)]
9989fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
9990    use std::process::Command;
9991
9992    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
9993        return Ok(None);
9994    }
9995
9996    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
9997    let mut cmd = Command::new("powershell");
9998    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
9999        package_full_name,
10000        &installed.package_family_names,
10001    ));
10002    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
10003    Ok(Some(status.code().unwrap_or(-1)))
10004}
10005
10006#[cfg(windows)]
10007fn arp_subkey_name(local_id: &str) -> Option<&str> {
10008    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
10009}
10010
10011#[cfg(windows)]
10012fn registry_entry_matches_installed_package(
10013    subkey_name: &str,
10014    display_name: &str,
10015    product_code: Option<&str>,
10016    installed: &ListMatch,
10017) -> bool {
10018    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
10019        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
10020    {
10021        return true;
10022    }
10023
10024    if installed.product_codes.iter().any(|code| {
10025        code.eq_ignore_ascii_case(subkey_name)
10026            || product_code
10027                .map(|value| code.eq_ignore_ascii_case(value))
10028                .unwrap_or(false)
10029    }) {
10030        return true;
10031    }
10032
10033    display_name.eq_ignore_ascii_case(&installed.name)
10034}
10035
10036#[cfg(all(windows, test))]
10037fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
10038    build_uninstall_command_with_mode(
10039        uninstall_cmd,
10040        if silent {
10041            InstallerMode::Silent
10042        } else {
10043            InstallerMode::Interactive
10044        },
10045        has_quiet_command,
10046        None,
10047    )
10048}
10049
10050#[cfg(windows)]
10051fn build_uninstall_command_with_mode(
10052    uninstall_cmd: &str,
10053    mode: InstallerMode,
10054    has_quiet_command: bool,
10055    log_path: Option<&str>,
10056) -> String {
10057    let uninstall_cmd = log_path
10058        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
10059        .unwrap_or_else(|| uninstall_cmd.to_owned());
10060    if mode == InstallerMode::Interactive || has_quiet_command {
10061        return uninstall_cmd;
10062    }
10063
10064    let lower = uninstall_cmd.to_ascii_lowercase();
10065    if is_winget_uninstall_command_lower(&lower) {
10066        return uninstall_cmd;
10067    }
10068    if lower.contains("/quiet")
10069        || lower.contains("/passive")
10070        || lower.contains("/verysilent")
10071        || lower.contains("/silent")
10072        || lower.contains(" /s")
10073    {
10074        return uninstall_cmd;
10075    }
10076
10077    format!("{uninstall_cmd} /S")
10078}
10079
10080#[cfg(windows)]
10081fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
10082    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
10083        return Ok(None);
10084    };
10085
10086    let exit_code = run_winget_uninstall(&args)?;
10087    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
10088        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
10089            return Ok(Some(exit_code));
10090        };
10091
10092        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
10093    }
10094
10095    Ok(Some(exit_code))
10096}
10097
10098#[cfg(windows)]
10099fn run_winget_uninstall(args: &[String]) -> Result<i32> {
10100    use std::process::Command;
10101
10102    let status = Command::new("winget")
10103        .args(args)
10104        .status()
10105        .context("failed to run delegated winget uninstall")?;
10106    Ok(status.code().unwrap_or(-1))
10107}
10108
10109#[cfg(windows)]
10110fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
10111    build_winget_uninstall_arguments_with_scope(installed, request, true)
10112}
10113
10114#[cfg(windows)]
10115fn build_winget_uninstall_arguments_with_scope(
10116    installed: &ListMatch,
10117    request: &UninstallRequest,
10118    include_scope: bool,
10119) -> Option<Vec<String>> {
10120    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
10121        return None;
10122    }
10123
10124    let mut args = vec![
10125        "uninstall".to_owned(),
10126        "--id".to_owned(),
10127        installed.id.clone(),
10128        "--exact".to_owned(),
10129        "--disable-interactivity".to_owned(),
10130    ];
10131
10132    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
10133        args.push("--source".to_owned());
10134        args.push(source_name.to_owned());
10135    }
10136
10137    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
10138        args.push("--scope".to_owned());
10139        args.push(scope.to_owned());
10140    }
10141
10142    if request.mode == InstallerMode::Silent {
10143        args.push("--silent".to_owned());
10144    }
10145
10146    Some(args)
10147}
10148
10149#[cfg(windows)]
10150fn is_winget_uninstall_command(command: &str) -> bool {
10151    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
10152}
10153
10154#[cfg(windows)]
10155fn is_winget_uninstall_command_lower(command: &str) -> bool {
10156    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
10157}
10158
10159#[cfg(windows)]
10160fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
10161    let full_name_literal = package_full_name
10162        .map(|value| format!("'{}'", value.replace('\'', "''")))
10163        .unwrap_or_else(|| "$null".to_owned());
10164    let family_names_literal = if package_family_names.is_empty() {
10165        "@()".to_owned()
10166    } else {
10167        format!(
10168            "@({})",
10169            package_family_names
10170                .iter()
10171                .map(|value| format!("'{}'", value.replace('\'', "''")))
10172                .collect::<Vec<_>>()
10173                .join(", ")
10174        )
10175    };
10176
10177    format!(
10178        "$fullName = {full_name_literal}; \
10179         $familyNames = {family_names_literal}; \
10180         $targets = Get-AppxPackage | Where-Object {{ \
10181         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
10182         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
10183         if (-not $targets) {{ exit 1 }}; \
10184         $targets | Remove-AppxPackage"
10185    )
10186}
10187
10188#[cfg(windows)]
10189fn try_run_msi_uninstall(
10190    installed: &ListMatch,
10191    subkey_name: &str,
10192    product_code: Option<&str>,
10193    request: &UninstallRequest,
10194) -> Result<Option<i32>> {
10195    use std::process::Command;
10196
10197    let uninstall_code = installed
10198        .product_codes
10199        .iter()
10200        .map(String::as_str)
10201        .chain(product_code)
10202        .chain(Some(subkey_name))
10203        .find(|value| is_product_code_like(value));
10204
10205    let Some(uninstall_code) = uninstall_code else {
10206        return Ok(None);
10207    };
10208
10209    let mut cmd = Command::new("msiexec");
10210    cmd.arg("/x").arg(uninstall_code);
10211    match request.mode {
10212        InstallerMode::Interactive => {}
10213        InstallerMode::SilentWithProgress => {
10214            cmd.arg("/passive").arg("/norestart");
10215        }
10216        InstallerMode::Silent => {
10217            cmd.arg("/quiet").arg("/norestart");
10218        }
10219    }
10220    if let Some(log_path) = &request.log_path {
10221        cmd.arg("/log").arg(log_path);
10222    }
10223
10224    let status = cmd.status().context("failed to run msiexec uninstall")?;
10225    Ok(Some(status.code().unwrap_or(-1)))
10226}
10227
10228#[cfg(windows)]
10229fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
10230    // Read PortableSymlinkFullPath from the ARP entry *before* we touch the
10231    // registry, so we can remove winget-created shims in Links\ if any. Without
10232    // this, a portable that winget originally installed (with a
10233    // PortableCommandAlias set in its manifest) would leave a dangling symlink
10234    // in %LOCALAPPDATA%\Microsoft\WinGet\Links\ once pinget uninstalls it.
10235    let known_symlink = read_portable_symlink_full_path(installed);
10236
10237    let Some(location) = installed.install_location.as_deref() else {
10238        if request.force {
10239            try_remove_portable_symlinks(installed, &known_symlink, None);
10240            try_remove_portable_registry_entry(installed);
10241            return Ok(0);
10242        }
10243        bail!(
10244            "Portable package '{}' does not expose an install location.",
10245            installed.name
10246        );
10247    };
10248
10249    if request.preserve {
10250        return Ok(0);
10251    }
10252
10253    let path = Path::new(location);
10254    let removed = if path.is_dir() {
10255        fs::remove_dir_all(path)?;
10256        true
10257    } else if path.is_file() {
10258        fs::remove_file(path)?;
10259        true
10260    } else {
10261        false
10262    };
10263
10264    if removed || request.force {
10265        // Once the files are gone (or the user forced past missing files), drop
10266        // the symlink shim and ARP entry too so the package fully disappears.
10267        try_remove_portable_symlinks(installed, &known_symlink, Some(path));
10268        try_remove_portable_registry_entry(installed);
10269        return Ok(0);
10270    }
10271
10272    bail!("Portable package location not found: {location}")
10273}
10274
10275/// Reads `PortableSymlinkFullPath` from the ARP subkey backing `installed`
10276/// when present. This value is recorded for portable installs that created a
10277/// `Links\<alias>.exe` symlink, including pinget-installed entries when
10278/// `install_portable` created that link. Entries without a created/recorded
10279/// portable symlink return `None`.
10280#[cfg(windows)]
10281fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
10282    const ARP_PREFIX: &str = r"ARP\";
10283    if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
10284        let mut parts = rest.splitn(3, '\\');
10285        let scope = parts.next().unwrap_or("");
10286        let _arch = parts.next();
10287        let subkey_name = parts.next().unwrap_or("");
10288        if subkey_name.is_empty() {
10289            return None;
10290        }
10291        let hive = if scope.eq_ignore_ascii_case("User") {
10292            RegKey::predef(HKEY_CURRENT_USER)
10293        } else {
10294            RegKey::predef(HKEY_LOCAL_MACHINE)
10295        };
10296        let subkey = hive
10297            .open_subkey(format!(
10298                r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
10299            ))
10300            .ok()?;
10301        return read_reg_string(&subkey, "PortableSymlinkFullPath");
10302    }
10303    None
10304}
10305
10306/// Removes the symlink shims winget would have created for this portable.
10307/// Tries (1) the exact path from `PortableSymlinkFullPath` if known, and
10308/// (2) any symlink in `%LOCALAPPDATA%\Microsoft\WinGet\Links\` whose target
10309/// resolves under `install_location` (defensive sweep for shims winget made
10310/// but didn't surface in the registry). Best-effort: failures are swallowed.
10311#[cfg(windows)]
10312fn try_remove_portable_symlinks(
10313    _installed: &ListMatch,
10314    known_symlink: &Option<String>,
10315    install_location: Option<&Path>,
10316) {
10317    if let Some(path) = known_symlink.as_deref() {
10318        let _ = fs::remove_file(path);
10319    }
10320
10321    // Defensive sweep: walk Links\ and remove shims whose stored target was
10322    // inside (or equal to) install_location. By this point install_location is
10323    // already deleted, so we compare the symlink's stored target string (which
10324    // the filesystem retains even after the target itself is gone).
10325    let Some(install_dir) = install_location else { return };
10326
10327    let Some(local) = dirs::data_local_dir() else { return };
10328    let links_root = local.join("Microsoft").join("WinGet").join("Links");
10329    let Ok(entries) = fs::read_dir(&links_root) else { return };
10330    for entry in entries.flatten() {
10331        let path = entry.path();
10332        let Ok(target) = fs::read_link(&path) else { continue };
10333        if path_starts_with_case_insensitive(&target, install_dir) {
10334            let _ = fs::remove_file(&path);
10335        }
10336    }
10337}
10338
10339/// Case-insensitive, component-aware path-prefix check for Windows. A raw
10340/// `str::starts_with` on the path strings would let `C:\Foo` falsely match
10341/// `C:\Foobar`; iterating path components avoids that. Used by the Links\
10342/// sweep so an uninstall of one portable can't accidentally delete shims that
10343/// belong to a different portable whose install dir happens to share a
10344/// string-prefix.
10345#[cfg(windows)]
10346fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
10347    let lower_components = |p: &Path| -> Vec<String> {
10348        p.components()
10349            .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
10350            .collect()
10351    };
10352    let parent_components = lower_components(parent);
10353    let child_components = lower_components(child);
10354    if parent_components.is_empty() || parent_components.len() > child_components.len() {
10355        return false;
10356    }
10357    parent_components
10358        .iter()
10359        .zip(child_components.iter())
10360        .all(|(a, b)| a == b)
10361}
10362
10363/// Best-effort removal of the HKCU/HKLM ARP subkey backing a portable entry.
10364/// We match on local_id (`ARP\<scope>\<arch>\<subkey>`) when present so we
10365/// delete the exact key the list view surfaced; otherwise we walk the standard
10366/// Uninstall path and match on WinGetPackageIdentifier. Failures are logged via
10367/// the returned warning chain in callers — we never bubble a registry-cleanup
10368/// failure as a hard error because the file deletion already succeeded.
10369#[cfg(windows)]
10370fn try_remove_portable_registry_entry(installed: &ListMatch) {
10371    // Collect (is_hkcu, subkey_path) pairs so we can re-open hives at delete
10372    // time instead of holding clones of RegKey (which doesn't implement Clone).
10373    let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
10374        // local_id format is `ARP\<scope>\<arch>\<subkey>` — pick the right hive/path.
10375        let mut parts = rest.splitn(3, '\\');
10376        let scope = parts.next().unwrap_or("");
10377        let _arch = parts.next();
10378        let subkey_name = parts.next().unwrap_or("");
10379        if subkey_name.is_empty() {
10380            return;
10381        }
10382        let is_hkcu = scope.eq_ignore_ascii_case("User");
10383        vec![(
10384            is_hkcu,
10385            format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
10386        )]
10387    } else {
10388        // Fall back to scanning by WinGetPackageIdentifier.
10389        let mut found = Vec::new();
10390        for (is_hkcu, hive) in [
10391            (true, RegKey::predef(HKEY_CURRENT_USER)),
10392            (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
10393        ] {
10394            let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
10395                continue;
10396            };
10397            for name in uninstall.enum_keys().flatten() {
10398                let Ok(subkey) = uninstall.open_subkey(&name) else {
10399                    continue;
10400                };
10401                if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
10402                    found.push((
10403                        is_hkcu,
10404                        format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
10405                    ));
10406                }
10407            }
10408        }
10409        found
10410    };
10411
10412    for (is_hkcu, path) in candidates {
10413        let hive = if is_hkcu {
10414            RegKey::predef(HKEY_CURRENT_USER)
10415        } else {
10416            RegKey::predef(HKEY_LOCAL_MACHINE)
10417        };
10418        let _ = hive.delete_subkey_all(&path);
10419    }
10420}
10421
10422#[cfg(windows)]
10423fn is_product_code_like(value: &str) -> bool {
10424    value.starts_with('{') && value.ends_with('}')
10425}
10426
10427#[cfg(not(windows))]
10428fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
10429    bail!("Uninstalling packages is only supported on Windows")
10430}
10431
10432/// Installs (or upgrades) a portable WinGet package.
10433///
10434/// Handles both `InstallerType: portable` (single binary download) and
10435/// `InstallerType: zip` with `NestedInstallerType: portable` (zip containing
10436/// the binary at a relative path), which is the shape every portable in the
10437/// community winget catalog uses today.
10438///
10439/// Behavior matches winget's portable workflow closely enough that the resulting
10440/// HKCU ARP entry is recognized by `winget list` and by pinget's own list view:
10441/// - Resolves the target install directory in this priority order:
10442///   1. `request.install_location` if provided (passed for upgrades)
10443///   2. Existing `InstallLocation` from the registry ARP entry (so upgrades
10444///      with no explicit location still preserve the user's custom path)
10445///   3. WinGet's default user portable root, `%LOCALAPPDATA%\Microsoft\WinGet
10446///      \Packages\<PackageId>_<SourceId>`
10447/// - If an existing pinget-owned install lives in that directory
10448///   (`InstallDirectoryCreated=1`), cleans its contents before extracting the
10449///   new version so leftover files from older versions don't pile up.
10450/// - Extracts the zip (or copies the standalone binary), then writes the ARP
10451///   registry entry that `winget list` and pinget's `collect_uninstall_view`
10452///   both read from.
10453#[cfg(windows)]
10454fn install_portable(
10455    installer_path: &Path,
10456    request: &InstallRequest,
10457    manifest: &Manifest,
10458    installer: &Installer,
10459) -> Result<i32> {
10460    let source_identifier = portable_source_identifier(&request.query);
10461    let existing_entry = read_existing_portable_entry(&manifest.id);
10462    // Reuse the existing subkey name on upgrade so we don't orphan the prior
10463    // entry by writing to a different one.
10464    let subkey_name = existing_entry
10465        .as_ref()
10466        .map(|entry| entry.subkey_name.clone())
10467        .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
10468
10469    let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
10470
10471    // If we created this directory on a previous install, wipe it clean so the
10472    // new version doesn't co-exist with leftovers from the previous one. We do
10473    // not touch a directory we don't own (InstallDirectoryCreated != 1), to
10474    // avoid stomping user data.
10475    let prev_dir_created = existing_entry
10476        .as_ref()
10477        .and_then(|entry| entry.install_directory_created)
10478        == Some(1);
10479    let dir_existed = target_dir.is_dir();
10480    if prev_dir_created && dir_existed {
10481        clean_directory_contents(&target_dir).with_context(|| {
10482            format!(
10483                "failed to clean existing portable install directory: {}",
10484                target_dir.display()
10485            )
10486        })?;
10487    }
10488
10489    fs::create_dir_all(&target_dir)
10490        .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
10491    // The "we created it" flag stays sticky across upgrades — if a previous
10492    // install marked it created, it still counts as created by us even when
10493    // the dir already existed this round.
10494    let install_directory_created = !dir_existed || prev_dir_created;
10495
10496    let installer_type = installer.installer_type.as_deref().unwrap_or("");
10497    let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
10498        let file = fs::File::open(installer_path)
10499            .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
10500        let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
10501        archive
10502            .extract(&target_dir)
10503            .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
10504        // For nested-portable zips the binary lives at the RelativeFilePath the
10505        // manifest declares. We record the first one as PortableTargetFullPath so
10506        // winget's portable uninstall workflow can identify which file to remove.
10507        installer
10508            .nested_installer_files
10509            .first()
10510            .map(|file| target_dir.join(&file.relative_file_path))
10511            .unwrap_or_else(|| target_dir.clone())
10512    } else {
10513        // Standalone portable: copy the downloaded file into target_dir using
10514        // its original filename. winget would optionally rename to the
10515        // PortableCommandAlias; pinget keeps the original name for now.
10516        let basename = installer_path
10517            .file_name()
10518            .ok_or_else(|| anyhow!("portable installer has no filename"))?;
10519        let copied = target_dir.join(basename);
10520        fs::copy(installer_path, &copied)
10521            .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
10522        copied
10523    };
10524
10525    // Create the Links\<alias>.exe shim so users can invoke the portable from
10526    // any shell, matching winget's portable workflow. Failures are non-fatal —
10527    // when the user lacks SeCreateSymbolicLink (no Developer Mode, not admin)
10528    // we still leave the package installed at install_location.
10529    let alias = determine_portable_alias(installer);
10530    let symlink_full_path = match alias.as_deref() {
10531        Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
10532        _ => None,
10533    };
10534
10535    // Ensure %LOCALAPPDATA%\Microsoft\WinGet\Links is on user PATH so the
10536    // symlink we just created is resolvable from new shells. Tracks whether we
10537    // added it this round so uninstall can decide whether to take it back out.
10538    let added_to_path = if symlink_full_path.is_some() {
10539        try_add_links_dir_to_user_path()
10540    } else {
10541        false
10542    };
10543
10544    write_portable_arp_entry(&PortableArpEntry {
10545        subkey_name: &subkey_name,
10546        install_location: &target_dir,
10547        portable_target_full_path: &portable_target_full_path,
10548        portable_symlink_full_path: symlink_full_path.as_deref(),
10549        install_directory_created,
10550        added_to_path,
10551        source_identifier: &source_identifier,
10552        manifest,
10553    })?;
10554
10555    Ok(0)
10556}
10557
10558#[cfg(windows)]
10559struct PortableArpEntry<'a> {
10560    subkey_name: &'a str,
10561    install_location: &'a Path,
10562    portable_target_full_path: &'a Path,
10563    portable_symlink_full_path: Option<&'a Path>,
10564    install_directory_created: bool,
10565    added_to_path: bool,
10566    source_identifier: &'a str,
10567    manifest: &'a Manifest,
10568}
10569
10570/// Picks the portable command alias from the manifest. Mirrors winget's
10571/// resolution: nested-installer files first (used by zip+portable manifests),
10572/// then the top-level `Commands` field (used by standalone portable manifests).
10573/// Returns None when the manifest doesn't declare a command — the package
10574/// still installs, just without a Links\ shim.
10575#[cfg(windows)]
10576fn determine_portable_alias(installer: &Installer) -> Option<String> {
10577    installer
10578        .nested_installer_files
10579        .iter()
10580        .find_map(|file| file.portable_command_alias.clone())
10581        .or_else(|| installer.commands.first().cloned())
10582}
10583
10584#[cfg(windows)]
10585fn winget_links_dir() -> PathBuf {
10586    dirs::data_local_dir()
10587        .unwrap_or_else(|| PathBuf::from("."))
10588        .join("Microsoft")
10589        .join("WinGet")
10590        .join("Links")
10591}
10592
10593/// Creates (or replaces) `Links\<alias>.exe` as a file symlink pointing at the
10594/// portable's binary. Returns the link path on success. Returns None and logs
10595/// a warning if the user can't create symlinks (no Developer Mode, not admin)
10596/// so the install can still complete without the shim.
10597#[cfg(windows)]
10598fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
10599    use std::os::windows::fs::symlink_file;
10600    let links_dir = winget_links_dir();
10601    if fs::create_dir_all(&links_dir).is_err() {
10602        return None;
10603    }
10604    let link_path = links_dir.join(format!("{alias}.exe"));
10605
10606    // Replace any prior link/file at the path so upgrades repoint to the new
10607    // binary instead of leaving a stale symlink behind.
10608    if link_path.symlink_metadata().is_ok() {
10609        let _ = fs::remove_file(&link_path);
10610    }
10611
10612    // Symlink creation fails with ERROR_PRIVILEGE_NOT_HELD (1314) when the
10613    // process lacks SeCreateSymbolicLink (no Developer Mode, not admin). Treat
10614    // that — and any other I/O failure — as non-fatal: the package files are
10615    // already in place, just without a Links\ shim.
10616    symlink_file(target, &link_path).ok().map(|()| link_path)
10617}
10618
10619/// Adds `Links\` to the user's HKCU\Environment\Path if not already present,
10620/// then broadcasts WM_SETTINGCHANGE so explorer-spawned shells pick up the
10621/// new PATH without a logoff. Returns true if we actually appended PATH this
10622/// call — callers should write `InstallDirectoryAddedToPath` accordingly so a
10623/// follow-up uninstall doesn't take PATH back out for other portables.
10624#[cfg(windows)]
10625fn try_add_links_dir_to_user_path() -> bool {
10626    let links_dir = winget_links_dir();
10627    let links_str = links_dir.to_string_lossy().to_string();
10628    let normalized_links = links_str.to_ascii_lowercase();
10629
10630    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10631    let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
10632        return false;
10633    };
10634    let existing: String = env_key.get_value("Path").unwrap_or_default();
10635    let already_present = existing
10636        .split(';')
10637        .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
10638    if already_present {
10639        return false;
10640    }
10641
10642    let new_path = if existing.is_empty() {
10643        links_str
10644    } else if existing.ends_with(';') {
10645        format!("{existing}{links_str}")
10646    } else {
10647        format!("{existing};{links_str}")
10648    };
10649
10650    if env_key.set_value("Path", &new_path).is_err() {
10651        return false;
10652    }
10653
10654    broadcast_environment_change();
10655    true
10656}
10657
10658#[cfg(windows)]
10659fn broadcast_environment_change() {
10660    use std::ffi::OsStr;
10661    use std::os::windows::ffi::OsStrExt;
10662
10663    use windows_sys::Win32::UI::WindowsAndMessaging::{
10664        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
10665    };
10666
10667    let param: Vec<u16> = OsStr::new("Environment")
10668        .encode_wide()
10669        .chain(std::iter::once(0))
10670        .collect();
10671    // SAFETY: SendMessageTimeoutW only reads `param` for the duration of the
10672    // call (it copies the string before returning), and `result` is a stack
10673    // local we provide. No aliasing or lifetime issues.
10674    unsafe {
10675        let mut result: usize = 0;
10676        SendMessageTimeoutW(
10677            HWND_BROADCAST,
10678            WM_SETTINGCHANGE,
10679            0usize,
10680            param.as_ptr() as isize,
10681            SMTO_ABORTIFHUNG,
10682            5000,
10683            &mut result,
10684        );
10685    }
10686}
10687
10688/// Source identifier embedded in the ARP subkey name. We mirror winget for
10689/// the community repo so winget's UninstallString can resolve the subkey and
10690/// `winget list` still finds the package; everything else uses the source
10691/// name verbatim.
10692#[cfg(windows)]
10693fn portable_source_identifier(query: &PackageQuery) -> String {
10694    match query.source.as_deref() {
10695        Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10696        Some(name) if !name.is_empty() => name.to_owned(),
10697        _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10698    }
10699}
10700
10701#[cfg(windows)]
10702fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
10703    format!("{package_id}_{source_identifier}")
10704}
10705
10706#[cfg(windows)]
10707struct ExistingPortableEntry {
10708    install_location: Option<PathBuf>,
10709    install_directory_created: Option<u32>,
10710    subkey_name: String,
10711}
10712
10713#[cfg(windows)]
10714fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
10715    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10716    let uninstall = hkcu
10717        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
10718        .ok()?;
10719    for name in uninstall.enum_keys().flatten() {
10720        let Ok(subkey) = uninstall.open_subkey(&name) else {
10721            continue;
10722        };
10723        if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
10724            continue;
10725        }
10726        if !read_reg_string(&subkey, "WinGetInstallerType")
10727            .as_deref()
10728            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
10729        {
10730            continue;
10731        }
10732        return Some(ExistingPortableEntry {
10733            install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
10734            install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
10735            subkey_name: name,
10736        });
10737    }
10738    None
10739}
10740
10741#[cfg(windows)]
10742fn resolve_portable_install_location(
10743    request: &InstallRequest,
10744    existing: &Option<ExistingPortableEntry>,
10745    subkey_name: &str,
10746) -> PathBuf {
10747    resolve_portable_install_location_pure(
10748        request.install_location.as_deref(),
10749        existing
10750            .as_ref()
10751            .and_then(|e| e.install_location.as_deref())
10752            .and_then(Path::to_str),
10753        subkey_name,
10754        &default_user_portable_root(),
10755    )
10756}
10757
10758/// Pure resolution rule for the portable install directory. Priority:
10759/// `request_location` (CLI override) → `existing_location` (so upgrades
10760/// preserve a winget-installed location) → default user portable root joined
10761/// with the package's ARP subkey name.
10762#[cfg(windows)]
10763fn resolve_portable_install_location_pure(
10764    request_location: Option<&str>,
10765    existing_location: Option<&str>,
10766    subkey_name: &str,
10767    default_user_portable_root: &Path,
10768) -> PathBuf {
10769    if let Some(custom) = request_location
10770        && !custom.is_empty()
10771    {
10772        return PathBuf::from(custom);
10773    }
10774    if let Some(loc) = existing_location
10775        && !loc.is_empty()
10776    {
10777        return PathBuf::from(loc);
10778    }
10779    default_user_portable_root.join(subkey_name)
10780}
10781
10782#[cfg(windows)]
10783fn default_user_portable_root() -> PathBuf {
10784    dirs::data_local_dir()
10785        .unwrap_or_else(|| PathBuf::from("."))
10786        .join("Microsoft")
10787        .join("WinGet")
10788        .join("Packages")
10789}
10790
10791#[cfg(windows)]
10792fn clean_directory_contents(dir: &Path) -> Result<()> {
10793    if !dir.is_dir() {
10794        return Ok(());
10795    }
10796    for entry in fs::read_dir(dir)? {
10797        let entry = entry?;
10798        let path = entry.path();
10799        if path.is_dir() {
10800            fs::remove_dir_all(&path)?;
10801        } else {
10802            fs::remove_file(&path)?;
10803        }
10804    }
10805    Ok(())
10806}
10807
10808/// Writes the HKCU ARP entry winget would normally write for a portable
10809/// install. Only the subset that winget's portable list view and pinget's
10810/// `collect_uninstall_view` actually read.
10811#[cfg(windows)]
10812fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
10813    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10814    let uninstall_path = format!(
10815        r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
10816        entry.subkey_name
10817    );
10818    let (subkey, _) = hkcu
10819        .create_subkey(&uninstall_path)
10820        .context("failed to create portable ARP registry subkey")?;
10821
10822    let manifest = entry.manifest;
10823    let install_location_str = entry.install_location.to_string_lossy().to_string();
10824    subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
10825    subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
10826    subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
10827    subkey.set_value("InstallLocation", &install_location_str)?;
10828    subkey.set_value(
10829        "PortableTargetFullPath",
10830        &entry.portable_target_full_path.to_string_lossy().to_string(),
10831    )?;
10832    if let Some(symlink_path) = entry.portable_symlink_full_path {
10833        subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
10834    }
10835    subkey.set_value(
10836        "InstallDirectoryAddedToPath",
10837        &(if entry.added_to_path { 1u32 } else { 0u32 }),
10838    )?;
10839    subkey.set_value(
10840        "InstallDirectoryCreated",
10841        &(if entry.install_directory_created { 1u32 } else { 0u32 }),
10842    )?;
10843    subkey.set_value(
10844        "DisplayName",
10845        &if manifest.name.is_empty() {
10846            manifest.id.clone()
10847        } else {
10848            manifest.name.clone()
10849        },
10850    )?;
10851    subkey.set_value("DisplayVersion", &manifest.version)?;
10852    if let Some(publisher) = manifest.publisher.as_ref() {
10853        subkey.set_value("Publisher", publisher)?;
10854    }
10855    subkey.set_value(
10856        "UninstallString",
10857        &format!("winget uninstall --product-code {}", entry.subkey_name),
10858    )?;
10859    let today = Utc::now().format("%Y%m%d").to_string();
10860    subkey.set_value("InstallDate", &today)?;
10861    if let Some(url) = manifest.package_url.as_ref() {
10862        subkey.set_value("URLInfoAbout", url)?;
10863    }
10864
10865    Ok(())
10866}
10867
10868#[cfg(test)]
10869mod tests {
10870    use std::io::Write;
10871    use std::net::{SocketAddr, TcpListener, TcpStream};
10872    use std::path::{Path, PathBuf};
10873    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
10874    use std::thread::{self, JoinHandle};
10875
10876    use flate2::Compression;
10877    use flate2::write::DeflateEncoder;
10878    use zip::write::SimpleFileOptions;
10879
10880    use super::*;
10881
10882    fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
10883        Manifest {
10884            id: id.to_owned(),
10885            name: name.to_owned(),
10886            version: version.to_owned(),
10887            channel: String::new(),
10888            publisher: Some("Acme".to_owned()),
10889            description: Some("desc".to_owned()),
10890            moniker: None,
10891            package_url: None,
10892            publisher_url: None,
10893            publisher_support_url: None,
10894            license: None,
10895            license_url: None,
10896            privacy_url: None,
10897            author: None,
10898            copyright: None,
10899            copyright_url: None,
10900            release_notes: None,
10901            release_notes_url: None,
10902            tags: Vec::new(),
10903            agreements: Vec::new(),
10904            package_dependencies: Vec::new(),
10905            documentation: Vec::new(),
10906            icons: Vec::new(),
10907            installers: Vec::new(),
10908            require_explicit_upgrade: false,
10909        }
10910    }
10911
10912    #[test]
10913    fn derive_download_filename_matches_winget_pattern() {
10914        let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
10915        let installer = Installer {
10916            architecture: Some("arm64".to_owned()),
10917            installer_type: Some("exe".to_owned()),
10918            scope: Some("user".to_owned()),
10919            locale: Some("en-US".to_owned()),
10920            ..Installer::default()
10921        };
10922        assert_eq!(
10923            derive_download_manifest_filename(&manifest, &installer),
10924            "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
10925        );
10926    }
10927
10928    #[test]
10929    fn derive_download_filename_omits_optional_components() {
10930        let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
10931        let installer = Installer {
10932            architecture: Some("x64".to_owned()),
10933            installer_type: Some("exe".to_owned()),
10934            ..Installer::default()
10935        };
10936        // No scope, no locale → both omitted; architecture remains PascalCase.
10937        assert_eq!(
10938            derive_download_manifest_filename(&manifest, &installer),
10939            "7-Zip_26.01_X64_exe.yaml"
10940        );
10941    }
10942
10943    #[test]
10944    fn derive_download_filename_skips_unknown_version() {
10945        let manifest = sample_manifest("Test.Package", "Unknown", "Test");
10946        let installer = Installer {
10947            architecture: Some("neutral".to_owned()),
10948            installer_type: Some("msi".to_owned()),
10949            ..Installer::default()
10950        };
10951        assert_eq!(
10952            derive_download_manifest_filename(&manifest, &installer),
10953            "Test_Neutral_msi.yaml"
10954        );
10955    }
10956
10957    #[test]
10958    fn derive_download_filename_sanitizes_illegal_chars() {
10959        let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
10960        let installer = Installer {
10961            architecture: Some("x64".to_owned()),
10962            installer_type: Some("exe".to_owned()),
10963            ..Installer::default()
10964        };
10965        // `/` and `:` are illegal in Windows filenames; both must be replaced.
10966        let filename = derive_download_manifest_filename(&manifest, &installer);
10967        assert!(!filename.contains('/') && !filename.contains(':'));
10968        assert!(filename.starts_with("Some_Bad_Name_1.0"));
10969    }
10970
10971    #[test]
10972    fn build_download_manifest_yaml_overrides_identity_fields() {
10973        let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10974        // Documents contain a stale PackageVersion (e.g. empty) — the typed
10975        // manifest's corrected version must win.
10976        let documents = serde_json::json!({
10977            "PackageIdentifier": "stale-id",
10978            "PackageVersion": "",
10979            "PackageName": "Old Name",
10980            "Channel": "preview",
10981            "Installers": [
10982                { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
10983                { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
10984            ],
10985        });
10986        let installer = Installer {
10987            architecture: Some("x64".to_owned()),
10988            installer_type: Some("exe".to_owned()),
10989            url: Some("https://example.com/a".to_owned()),
10990            ..Installer::default()
10991        };
10992        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10993        assert!(yaml.contains("PackageIdentifier: App.Id"));
10994        assert!(yaml.contains("PackageVersion: 2.0.0"));
10995        assert!(yaml.contains("PackageName: App Name"));
10996        assert!(yaml.contains("ManifestType: merged"));
10997        // Channel had a value in docs but the typed manifest's channel is empty
10998        // → expect it stripped, since winget download manifests don't carry it.
10999        assert!(!yaml.contains("Channel: preview"));
11000        // Installers array narrows to the single selected installer.
11001        assert_eq!(yaml.matches("InstallerUrl").count(), 1);
11002        assert!(yaml.contains("https://example.com/a"));
11003        assert!(!yaml.contains("https://example.com/b"));
11004    }
11005
11006    #[test]
11007    fn build_download_manifest_yaml_preserves_typed_channel() {
11008        let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
11009        manifest.channel = "beta".to_owned();
11010        let documents = serde_json::json!({
11011            "PackageIdentifier": "App.Id",
11012            "PackageVersion": "2.0.0",
11013            "Installers": [],
11014        });
11015        let installer = Installer::default();
11016        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
11017        assert!(yaml.contains("Channel: beta"));
11018    }
11019
11020    fn temp_app_root(label: &str) -> PathBuf {
11021        static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
11022        std::env::temp_dir().join(format!(
11023            "pinget-rs-tests-{label}-{}-{}",
11024            std::process::id(),
11025            TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
11026        ))
11027    }
11028
11029    #[cfg(windows)]
11030    #[test]
11031    fn path_prefix_check_rejects_string_prefix_collisions() {
11032        // Regression: a naive str::starts_with would falsely consider
11033        // C:\Foobar a child of C:\Foo and let the Links\ sweep delete unrelated
11034        // symlinks. The component-aware check must reject these.
11035        assert!(!path_starts_with_case_insensitive(
11036            Path::new(r"C:\Foobar\bin.exe"),
11037            Path::new(r"C:\Foo"),
11038        ));
11039        assert!(!path_starts_with_case_insensitive(
11040            Path::new(r"C:\Foo\bin.exe.bak"),
11041            Path::new(r"C:\Foo\bin.exe"),
11042        ));
11043    }
11044
11045    #[cfg(windows)]
11046    #[test]
11047    fn path_prefix_check_accepts_real_children_and_self() {
11048        // Dir install: the symlink target lives inside the install dir.
11049        assert!(path_starts_with_case_insensitive(
11050            Path::new(r"C:\Foo\sub\bin.exe"),
11051            Path::new(r"C:\Foo"),
11052        ));
11053        // File install: the symlink points at the same file we removed.
11054        assert!(path_starts_with_case_insensitive(
11055            Path::new(r"C:\Foo\bin.exe"),
11056            Path::new(r"C:\Foo\bin.exe"),
11057        ));
11058    }
11059
11060    #[cfg(windows)]
11061    #[test]
11062    fn path_prefix_check_is_case_insensitive() {
11063        // Windows paths are case-insensitive — winget and pinget may write
11064        // them with different casing.
11065        assert!(path_starts_with_case_insensitive(
11066            Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
11067            Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
11068        ));
11069    }
11070
11071    #[cfg(windows)]
11072    #[test]
11073    fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
11074        let query = PackageQuery {
11075            source: Some("winget".to_owned()),
11076            ..PackageQuery::default()
11077        };
11078        assert_eq!(
11079            portable_source_identifier(&query),
11080            "Microsoft.Winget.Source_8wekyb3d8bbwe"
11081        );
11082        // Case-insensitive — manifests/catalog may report differently.
11083        let query = PackageQuery {
11084            source: Some("WINGET".to_owned()),
11085            ..PackageQuery::default()
11086        };
11087        assert_eq!(
11088            portable_source_identifier(&query),
11089            "Microsoft.Winget.Source_8wekyb3d8bbwe"
11090        );
11091    }
11092
11093    #[cfg(windows)]
11094    #[test]
11095    fn portable_source_identifier_passes_through_custom_sources() {
11096        let query = PackageQuery {
11097            source: Some("internal-feed".to_owned()),
11098            ..PackageQuery::default()
11099        };
11100        assert_eq!(portable_source_identifier(&query), "internal-feed");
11101    }
11102
11103    #[cfg(windows)]
11104    #[test]
11105    fn portable_source_identifier_defaults_for_missing_or_empty_source() {
11106        let query = PackageQuery {
11107            source: None,
11108            ..PackageQuery::default()
11109        };
11110        assert_eq!(
11111            portable_source_identifier(&query),
11112            "Microsoft.Winget.Source_8wekyb3d8bbwe"
11113        );
11114        let query = PackageQuery {
11115            source: Some(String::new()),
11116            ..PackageQuery::default()
11117        };
11118        assert_eq!(
11119            portable_source_identifier(&query),
11120            "Microsoft.Winget.Source_8wekyb3d8bbwe"
11121        );
11122    }
11123
11124    #[cfg(windows)]
11125    #[test]
11126    fn portable_subkey_name_matches_winget_format() {
11127        assert_eq!(
11128            portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
11129            "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
11130        );
11131    }
11132
11133    #[cfg(windows)]
11134    #[test]
11135    fn determine_portable_alias_prefers_nested_installer_alias() {
11136        let installer = Installer {
11137            nested_installer_files: vec![NestedInstallerFile {
11138                relative_file_path: "ripgrep/rg.exe".to_owned(),
11139                portable_command_alias: Some("rg".to_owned()),
11140            }],
11141            commands: vec!["other-name".to_owned()],
11142            ..Installer::default()
11143        };
11144        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
11145    }
11146
11147    #[cfg(windows)]
11148    #[test]
11149    fn determine_portable_alias_falls_back_to_commands() {
11150        let installer = Installer {
11151            // No nested files at all (standalone portable case).
11152            commands: vec!["nuget".to_owned()],
11153            ..Installer::default()
11154        };
11155        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
11156
11157        // Nested files exist but none declare PortableCommandAlias.
11158        let installer = Installer {
11159            nested_installer_files: vec![NestedInstallerFile {
11160                relative_file_path: "subdir/bin.exe".to_owned(),
11161                portable_command_alias: None,
11162            }],
11163            commands: vec!["bin".to_owned()],
11164            ..Installer::default()
11165        };
11166        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
11167    }
11168
11169    #[cfg(windows)]
11170    #[test]
11171    fn determine_portable_alias_returns_none_without_alias_or_commands() {
11172        let installer = Installer::default();
11173        assert!(determine_portable_alias(&installer).is_none());
11174    }
11175
11176    #[cfg(windows)]
11177    #[test]
11178    fn resolve_portable_install_location_prefers_request_override() {
11179        let result = resolve_portable_install_location_pure(
11180            Some(r"D:\Tools\rg"),
11181            Some(r"C:\Software\rg-test"),
11182            "Sub_Source",
11183            Path::new(r"C:\default"),
11184        );
11185        assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
11186    }
11187
11188    #[cfg(windows)]
11189    #[test]
11190    fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
11191        // The #4823 scenario: pinget upgrade with no explicit --location must
11192        // resolve to the install_location the prior install recorded, not the
11193        // default portable root.
11194        let result = resolve_portable_install_location_pure(
11195            None,
11196            Some(r"C:\Software\rg-test"),
11197            "Sub_Source",
11198            Path::new(r"C:\default"),
11199        );
11200        assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
11201    }
11202
11203    #[cfg(windows)]
11204    #[test]
11205    fn resolve_portable_install_location_falls_back_to_default_root() {
11206        let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
11207        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
11208    }
11209
11210    #[cfg(windows)]
11211    #[test]
11212    fn resolve_portable_install_location_treats_empty_strings_as_unset() {
11213        // Defensive: PackageQuery / registry reads can yield "" instead of
11214        // None for missing values.
11215        let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
11216        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
11217    }
11218
11219    #[cfg(windows)]
11220    #[test]
11221    fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
11222        let root = std::env::temp_dir().join(format!(
11223            "pinget-clean-test-{}",
11224            Utc::now().timestamp_nanos_opt().unwrap_or_default()
11225        ));
11226        fs::create_dir_all(&root).expect("create temp dir");
11227        fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
11228        fs::create_dir_all(root.join("nested")).expect("create nested dir");
11229        fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
11230
11231        clean_directory_contents(&root).expect("clean root contents");
11232
11233        assert!(root.is_dir(), "root dir itself must survive");
11234        assert_eq!(
11235            fs::read_dir(&root).expect("read root").count(),
11236            0,
11237            "root must be empty after clean"
11238        );
11239        let _ = fs::remove_dir_all(&root);
11240    }
11241
11242    #[cfg(windows)]
11243    #[test]
11244    fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
11245        let bogus = std::env::temp_dir().join(format!(
11246            "pinget-clean-test-bogus-{}",
11247            Utc::now().timestamp_nanos_opt().unwrap_or_default()
11248        ));
11249        assert!(!bogus.exists());
11250        clean_directory_contents(&bogus).expect("clean nonexistent dir");
11251        // Should not have created the dir.
11252        assert!(!bogus.exists());
11253    }
11254
11255    #[cfg(windows)]
11256    #[test]
11257    fn path_prefix_check_handles_mixed_separators() {
11258        // NestedInstallerFiles RelativeFilePath uses '/' in manifests, so the
11259        // path we joined can end up mixed-separator on Windows.
11260        assert!(path_starts_with_case_insensitive(
11261            Path::new(r"C:\Foo\sub/bin.exe"),
11262            Path::new(r"C:\Foo"),
11263        ));
11264    }
11265
11266    #[test]
11267    fn repository_options_capture_custom_host_settings() {
11268        let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
11269            .with_user_agent("pinget-rs-tests/1.0")
11270            .with_pre_indexed_source_auto_update_interval(None);
11271
11272        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
11273        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
11274        assert!(options.pre_indexed_source_auto_update_interval.is_none());
11275    }
11276
11277    #[test]
11278    fn dependency_install_requests_are_silent_and_not_forced() {
11279        let mut parent = InstallRequest::new(PackageQuery {
11280            source: Some("winget".to_owned()),
11281            ..PackageQuery::default()
11282        });
11283        parent.mode = InstallerMode::SilentWithProgress;
11284        parent.force = true;
11285        parent.accept_package_agreements = true;
11286        parent.ignore_security_hash = true;
11287        parent.dependency_source = Some("dependencies".to_owned());
11288
11289        let dependency =
11290            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
11291
11292        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
11293        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
11294        assert_eq!(dependency.mode, InstallerMode::Silent);
11295        assert!(!dependency.force);
11296        assert!(dependency.no_upgrade);
11297        assert!(dependency.accept_package_agreements);
11298        assert!(dependency.ignore_security_hash);
11299    }
11300
11301    #[test]
11302    fn storage_paths_use_configured_app_root() {
11303        let app_root = PathBuf::from(r"C:\temp\pinget-test");
11304        let source = SourceRecord {
11305            name: "winget/test".to_owned(),
11306            kind: SourceKind::PreIndexed,
11307            arg: "https://example.com/cache".to_owned(),
11308            identifier: "Test.Source".to_owned(),
11309            trust_level: "None".to_owned(),
11310            explicit: false,
11311            priority: 0,
11312            last_update: None,
11313            source_version: None,
11314            etag: None,
11315            last_modified: None,
11316        };
11317
11318        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
11319        assert_eq!(
11320            source_state_dir(&app_root, &source),
11321            app_root.join("sources").join("winget_test")
11322        );
11323        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
11324    }
11325
11326    #[cfg(windows)]
11327    #[test]
11328    fn packaged_layout_paths_resolve_from_packaged_app_root() {
11329        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
11330        let app_root = local_app_data
11331            .join("Packages")
11332            .join(PACKAGED_FAMILY_NAME)
11333            .join("LocalState");
11334        let source = SourceStore::default()
11335            .sources
11336            .into_iter()
11337            .find(|source| source.name == "winget")
11338            .expect("winget source");
11339        assert!(uses_packaged_layout(&app_root));
11340        assert!(
11341            user_settings_path(&app_root)
11342                .display()
11343                .to_string()
11344                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
11345        );
11346        assert!(
11347            packaged_file_cache_root(&app_root)
11348                .display()
11349                .to_string()
11350                .ends_with(&format!(
11351                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
11352                    PACKAGED_FAMILY_NAME
11353                ))
11354        );
11355        assert!(
11356            source_state_dir(&app_root, &source)
11357                .display()
11358                .to_string()
11359                .ends_with(&format!(
11360                    r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe",
11361                    PACKAGED_FAMILY_NAME
11362                ))
11363        );
11364        assert!(
11365            source_installed_db_path_with_keying(&app_root, &source, false)
11366                .display()
11367                .to_string()
11368                .ends_with(&format!(
11369                    r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db",
11370                    PACKAGED_FAMILY_NAME
11371                ))
11372        );
11373    }
11374
11375    #[test]
11376    fn source_mode_parses_accepted_spellings() {
11377        assert_eq!(SourceMode::parse("auto"), Some(SourceMode::Auto));
11378        assert_eq!(SourceMode::parse("  Auto  "), Some(SourceMode::Auto));
11379        assert_eq!(SourceMode::parse("PRIVATE"), Some(SourceMode::Private));
11380        assert_eq!(
11381            SourceMode::parse("system-winget-mirror"),
11382            Some(SourceMode::SystemWingetMirror)
11383        );
11384        assert_eq!(
11385            SourceMode::parse("SystemWingetMirror"),
11386            Some(SourceMode::SystemWingetMirror)
11387        );
11388    }
11389
11390    #[test]
11391    fn source_mode_rejects_unknown_spellings() {
11392        assert_eq!(SourceMode::parse(""), None);
11393        assert_eq!(SourceMode::parse("mirror"), None);
11394        assert_eq!(SourceMode::parse("system_winget_mirror"), None);
11395    }
11396
11397    #[test]
11398    fn source_mode_resolution_keeps_private_default_for_a_custom_app_root() {
11399        assert_eq!(SourceMode::resolve(None, true), SourceMode::Private);
11400        assert_eq!(SourceMode::resolve(None, false), SourceMode::Auto);
11401    }
11402
11403    #[test]
11404    fn source_mode_resolution_lets_the_environment_override_a_custom_app_root() {
11405        assert_eq!(SourceMode::resolve(Some(SourceMode::Auto), true), SourceMode::Auto);
11406        assert_eq!(
11407            SourceMode::resolve(Some(SourceMode::SystemWingetMirror), true),
11408            SourceMode::SystemWingetMirror
11409        );
11410        assert_eq!(
11411            SourceMode::resolve(Some(SourceMode::Private), false),
11412            SourceMode::Private
11413        );
11414    }
11415
11416    #[cfg(windows)]
11417    #[test]
11418    fn default_app_root_outside_package_avoids_packaged_layout() {
11419        // Tests run without AppX package identity, so the default app root must not
11420        // resolve to the WinGet packaged LocalState — that location requires a
11421        // brokered/elevated writer for its secure-settings stream.
11422        let prior = std::env::var_os(APP_ROOT_ENV_VAR);
11423        // SAFETY: tests in this module are not run concurrently with other env mutators.
11424        unsafe { std::env::remove_var(APP_ROOT_ENV_VAR) };
11425
11426        let result = default_app_root();
11427
11428        if let Some(prior) = prior {
11429            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
11430            unsafe { std::env::set_var(APP_ROOT_ENV_VAR, prior) };
11431        }
11432
11433        let app_root = result.expect("default app root");
11434        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
11435        assert!(
11436            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
11437            "unexpected fallback app root: {app_root:?}"
11438        );
11439    }
11440
11441    #[test]
11442    fn packaged_source_stream_overlays_defaults_and_metadata() {
11443        let store = parse_packaged_source_store(
11444            Some(
11445                "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",
11446            ),
11447            Some(
11448                "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",
11449            ),
11450        )
11451        .expect("packaged store");
11452
11453        assert!(!store.sources.iter().any(|source| source.name == "winget"));
11454        let corp = store
11455            .sources
11456            .iter()
11457            .find(|source| source.name == "corp")
11458            .expect("corp source");
11459        assert_eq!(corp.kind, SourceKind::Rest);
11460        assert_eq!(corp.identifier, "Contoso.Rest");
11461        assert_eq!(corp.trust_level, "Trusted");
11462        assert!(corp.explicit);
11463        assert_eq!(corp.priority, 7);
11464        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
11465        assert_eq!(corp.etag.as_deref(), Some("etag-v1"));
11466        assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT"));
11467        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
11468    }
11469
11470    #[test]
11471    fn system_winget_export_parses_json_lines() {
11472        let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11473{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11474{"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"}
11475{"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"}
11476"#;
11477
11478        let sources = parse_system_winget_source_export(output).expect("source export");
11479
11480        assert_eq!(
11481            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
11482            vec!["winget.pro", "msstore", "winget", "winget-font"]
11483        );
11484        assert_eq!(sources[0].kind, SourceKind::Rest);
11485        assert_eq!(sources[0].arg, "https://api.winget.pro/feed");
11486        assert_eq!(sources[0].identifier, "api.winget.pro");
11487        assert_eq!(sources[0].trust_level, "Trusted");
11488        assert!(!sources[0].explicit);
11489        assert_eq!(sources[2].kind, SourceKind::PreIndexed);
11490        assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe");
11491        assert!(!sources[2].explicit);
11492    }
11493
11494    #[test]
11495    fn system_winget_export_parses_wrapped_sources() {
11496        let output = r#"{
11497  "Sources": [
11498    {
11499      "Name": "corp",
11500      "Type": "Microsoft.Rest",
11501      "Arg": "https://packages.contoso.test/api",
11502      "Identifier": "Contoso.Rest",
11503      "TrustLevel": "Trusted",
11504      "Explicit": true,
11505      "Priority": 9
11506    }
11507  ]
11508}"#;
11509
11510        let sources = parse_system_winget_source_export(output).expect("source export");
11511        let source = sources.first().expect("source");
11512        assert_eq!(sources.len(), 1);
11513        assert_eq!(source.name, "corp");
11514        assert_eq!(source.kind, SourceKind::Rest);
11515        assert_eq!(source.identifier, "Contoso.Rest");
11516        assert_eq!(source.trust_level, "Trusted");
11517        assert!(source.explicit);
11518        assert_eq!(source.priority, 9);
11519    }
11520
11521    #[test]
11522    fn system_winget_source_store_detects_secure_settings_stub() {
11523        assert!(is_secure_settings_stub(Some(
11524            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
11525        )));
11526        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
11527    }
11528
11529    #[test]
11530    fn system_winget_source_store_builds_add_arguments() {
11531        let args = build_winget_source_add_arguments(
11532            "corp",
11533            "https://packages.contoso.test/api",
11534            SourceKind::Rest,
11535            "Trusted",
11536            true,
11537        );
11538
11539        assert_eq!(
11540            args,
11541            vec![
11542                "source",
11543                "add",
11544                "--name",
11545                "corp",
11546                "--arg",
11547                "https://packages.contoso.test/api",
11548                "--type",
11549                "Microsoft.Rest",
11550                "--disable-interactivity",
11551                "--trust-level",
11552                "trusted",
11553                "--explicit",
11554            ]
11555        );
11556    }
11557
11558    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
11559        Ok(WingetSourceCommandResult {
11560            exit_code: 0,
11561            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11562"#
11563            .to_owned(),
11564            stderr: String::new(),
11565        })
11566    }
11567
11568    fn failing_system_winget_source_command(_args: &[String]) -> Result<WingetSourceCommandResult> {
11569        bail!("{SYSTEM_WINGET_PROGRAM_NAME} was not found on the PATH")
11570    }
11571
11572    static SYSTEM_WINGET_RUNNER_LOCK: Mutex<()> = Mutex::new(());
11573
11574    fn lock_system_winget_source_command_runner() -> std::sync::MutexGuard<'static, ()> {
11575        SYSTEM_WINGET_RUNNER_LOCK
11576            .lock()
11577            .unwrap_or_else(|poisoned| poisoned.into_inner())
11578    }
11579
11580    fn swap_system_winget_source_command_runner(runner: WingetSourceCommandRunner) -> WingetSourceCommandRunner {
11581        let mut current = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11582        let previous = *current;
11583        *current = runner;
11584        previous
11585    }
11586
11587    fn search_path_of(directories: &[&Path]) -> OsString {
11588        std::env::join_paths(directories).expect("search path")
11589    }
11590
11591    #[test]
11592    fn configured_system_winget_program_wins_over_the_search_path() {
11593        let configured = PathBuf::from("tools").join(SYSTEM_WINGET_PROGRAM_NAME);
11594        let on_path = PathBuf::from("windows");
11595
11596        let program = resolve_system_winget_program_from(
11597            Some(configured.as_os_str().to_os_string()),
11598            None,
11599            Some(search_path_of(&[on_path.as_path()])),
11600            &Vec::new,
11601            &|candidate| candidate == configured || candidate == on_path.join(SYSTEM_WINGET_PROGRAM_NAME),
11602        )
11603        .expect("program");
11604
11605        assert_eq!(program, configured);
11606    }
11607
11608    #[test]
11609    fn configured_system_winget_directory_resolves_to_the_executable() {
11610        let directory = PathBuf::from("tools");
11611        let program = directory.join(SYSTEM_WINGET_PROGRAM_NAME);
11612
11613        let resolved = resolve_system_winget_program_from(
11614            Some(directory.as_os_str().to_os_string()),
11615            None,
11616            None,
11617            &Vec::new,
11618            &|candidate| candidate == program,
11619        )
11620        .expect("program");
11621
11622        assert_eq!(resolved, program);
11623    }
11624
11625    #[test]
11626    fn configured_system_winget_program_that_is_missing_is_reported() {
11627        let error = resolve_system_winget_program_from(
11628            Some(PathBuf::from("tools").into_os_string()),
11629            None,
11630            None,
11631            &Vec::new,
11632            &|_| false,
11633        )
11634        .expect_err("missing configured program");
11635
11636        assert!(
11637            error.to_string().contains(SYSTEM_WINGET_PROGRAM_ENV_VAR),
11638            "unexpected error: {error}"
11639        );
11640    }
11641
11642    #[test]
11643    fn system_winget_program_is_found_on_the_search_path() {
11644        let first = PathBuf::from("empty");
11645        let second = PathBuf::from("windows");
11646        let program = second.join(SYSTEM_WINGET_PROGRAM_NAME);
11647
11648        let resolved = resolve_system_winget_program_from(
11649            None,
11650            None,
11651            Some(search_path_of(&[first.as_path(), second.as_path()])),
11652            &Vec::new,
11653            &|candidate| candidate == program,
11654        )
11655        .expect("program");
11656
11657        assert_eq!(resolved, program);
11658    }
11659
11660    #[test]
11661    fn system_winget_program_falls_back_to_the_app_installer_locations() {
11662        let on_path = PathBuf::from("windows");
11663        let alias_directory = PathBuf::from("WindowsApps");
11664        let program = alias_directory.join(SYSTEM_WINGET_PROGRAM_NAME);
11665
11666        let resolved = resolve_system_winget_program_from(
11667            None,
11668            None,
11669            Some(search_path_of(&[on_path.as_path()])),
11670            &|| vec![alias_directory.clone()],
11671            &|candidate| candidate == program,
11672        )
11673        .expect("program");
11674
11675        assert_eq!(resolved, program);
11676    }
11677
11678    #[test]
11679    fn unresolvable_system_winget_program_points_at_the_environment_variable() {
11680        let error = resolve_system_winget_program_from(
11681            None,
11682            None,
11683            Some(search_path_of(&[Path::new("windows")])),
11684            &Vec::new,
11685            &|_| false,
11686        )
11687        .expect_err("no program");
11688
11689        assert!(
11690            error.to_string().contains(SYSTEM_WINGET_PROGRAM_ENV_VAR),
11691            "unexpected error: {error}"
11692        );
11693    }
11694
11695    #[test]
11696    fn the_application_directory_keeps_precedence_over_the_search_path() {
11697        let application_directory = PathBuf::from("app");
11698        let on_path = PathBuf::from("windows");
11699        let bundled = application_directory.join(SYSTEM_WINGET_PROGRAM_NAME);
11700        let on_path_program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME);
11701
11702        let resolved = resolve_system_winget_program_from(
11703            None,
11704            Some(application_directory),
11705            Some(search_path_of(&[on_path.as_path()])),
11706            &Vec::new,
11707            &|candidate| candidate == bundled || candidate == on_path_program,
11708        )
11709        .expect("program");
11710
11711        assert_eq!(resolved, bundled);
11712    }
11713
11714    #[test]
11715    fn the_app_installer_locations_are_not_enumerated_when_the_path_resolves_winget() {
11716        let on_path = PathBuf::from("windows");
11717        let program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME);
11718        let enumerated = AtomicBool::new(false);
11719
11720        let resolved = resolve_system_winget_program_from(
11721            None,
11722            None,
11723            Some(search_path_of(&[on_path.as_path()])),
11724            &|| {
11725                enumerated.store(true, AtomicOrdering::SeqCst);
11726                Vec::new()
11727            },
11728            &|candidate| candidate == program,
11729        )
11730        .expect("program");
11731
11732        assert_eq!(resolved, program);
11733        assert!(!enumerated.load(AtomicOrdering::SeqCst));
11734    }
11735
11736    #[test]
11737    fn a_blank_configured_system_winget_program_falls_through_to_the_path() {
11738        let on_path = PathBuf::from("windows");
11739        let program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME);
11740
11741        let resolved = resolve_system_winget_program_from(
11742            Some(OsString::from(" ")),
11743            None,
11744            Some(search_path_of(&[on_path.as_path()])),
11745            &Vec::new,
11746            &|candidate| candidate == program,
11747        )
11748        .expect("program");
11749
11750        assert_eq!(resolved, program);
11751    }
11752
11753    #[test]
11754    fn mirror_freshness_follows_the_export_stamp_not_the_store_file() {
11755        let app_root = temp_app_root("mirror-freshness");
11756        assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900)));
11757
11758        // A pre-indexed metadata save rewrites the mirror store without re-exporting.
11759        save_system_winget_mirror_store(&app_root, &SourceStore::default()).expect("save mirror");
11760        assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900)));
11761
11762        stamp_system_winget_mirror_export(&app_root);
11763        assert!(system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900)));
11764        assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::ZERO));
11765
11766        let _ = fs::remove_dir_all(&app_root);
11767    }
11768
11769    #[test]
11770    fn a_query_keeps_the_cached_mirror_when_the_system_winget_cannot_be_run() {
11771        let _runner_guard = lock_system_winget_source_command_runner();
11772        let app_root = temp_app_root("mirror-refresh-failure");
11773        let previous = swap_system_winget_source_command_runner(fake_system_winget_source_export);
11774        let opened = Repository::open_with_options(
11775            RepositoryOptions::new(app_root.clone()).with_source_mode(SourceMode::SystemWingetMirror),
11776        );
11777        swap_system_winget_source_command_runner(previous);
11778
11779        let result = (|| -> Result<()> {
11780            let mut repository = opened?;
11781            let cached: Vec<String> = repository
11782                .list_sources()
11783                .into_iter()
11784                .map(|source| source.name)
11785                .collect();
11786            assert_eq!(cached, vec!["contoso".to_owned()]);
11787
11788            let previous = swap_system_winget_source_command_runner(failing_system_winget_source_command);
11789            let warning = repository.refresh_system_winget_sources_for_query_with_max_age(StdDuration::ZERO);
11790            swap_system_winget_source_command_runner(previous);
11791
11792            let warning = warning.expect("refresh warning");
11793            assert!(
11794                warning.contains("Could not refresh the system WinGet sources"),
11795                "{warning}"
11796            );
11797            let kept: Vec<String> = repository
11798                .list_sources()
11799                .into_iter()
11800                .map(|source| source.name)
11801                .collect();
11802            assert_eq!(kept, cached);
11803            Ok(())
11804        })();
11805
11806        let _ = fs::remove_dir_all(&app_root);
11807        result.expect("cached mirror query");
11808    }
11809
11810    #[test]
11811    fn a_query_does_not_re_export_a_fresh_mirror() {
11812        let _runner_guard = lock_system_winget_source_command_runner();
11813        let app_root = temp_app_root("mirror-refresh-fresh");
11814        let previous = swap_system_winget_source_command_runner(fake_system_winget_source_export);
11815        let opened = Repository::open_with_options(
11816            RepositoryOptions::new(app_root.clone()).with_source_mode(SourceMode::SystemWingetMirror),
11817        );
11818        swap_system_winget_source_command_runner(previous);
11819
11820        let result = (|| -> Result<()> {
11821            let mut repository = opened?;
11822            let previous = swap_system_winget_source_command_runner(failing_system_winget_source_command);
11823            let warning = repository.refresh_system_winget_sources_for_query();
11824            swap_system_winget_source_command_runner(previous);
11825
11826            assert!(warning.is_none(), "{warning:?}");
11827            Ok(())
11828        })();
11829
11830        let _ = fs::remove_dir_all(&app_root);
11831        result.expect("fresh mirror query");
11832    }
11833
11834    #[test]
11835    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
11836        let _runner_guard = lock_system_winget_source_command_runner();
11837        let original_runner = {
11838            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11839            let original = *runner;
11840            *runner = fake_system_winget_source_export;
11841            original
11842        };
11843
11844        let result = load_packaged_store_from_streams(
11845            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
11846            None,
11847        );
11848
11849        {
11850            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11851            *runner = original_runner;
11852        }
11853
11854        let store = result.expect("packaged store").expect("packaged store");
11855        let source = store.sources.first().expect("source");
11856        assert_eq!(store.sources.len(), 1);
11857        assert_eq!(source.name, "contoso");
11858        assert_eq!(source.kind, SourceKind::Rest);
11859    }
11860
11861    #[test]
11862    fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() {
11863        let _runner_guard = lock_system_winget_source_command_runner();
11864        let original_runner = {
11865            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11866            let original = *runner;
11867            *runner = fake_system_winget_source_export;
11868            original
11869        };
11870
11871        let app_root = temp_app_root("system-winget-mirror");
11872        let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror);
11873
11874        {
11875            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11876            *runner = original_runner;
11877        }
11878
11879        let (mode, mut store) = result.expect("mirror store");
11880        assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror);
11881        assert!(system_winget_mirror_store_path(&app_root).exists());
11882        assert!(
11883            !store_path(&app_root).exists(),
11884            "mirror mode must not write private sources.json"
11885        );
11886
11887        let source = store.sources.first_mut().expect("source");
11888        source.last_update = Some(Utc::now());
11889        source.source_version = Some("cached-contract".to_owned());
11890        source.etag = Some("\"etag\"".to_owned());
11891        source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
11892        save_system_winget_mirror_store(&app_root, &store).expect("save mirror");
11893
11894        let original_runner = {
11895            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11896            let original = *runner;
11897            *runner = fake_system_winget_source_export;
11898            original
11899        };
11900        let refreshed = refresh_system_winget_mirror_store(&app_root);
11901        {
11902            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11903            *runner = original_runner;
11904        }
11905
11906        let refreshed = refreshed.expect("refresh mirror");
11907        let source = refreshed.sources.first().expect("source");
11908        assert_eq!(source.source_version.as_deref(), Some("cached-contract"));
11909        assert_eq!(source.etag.as_deref(), Some("\"etag\""));
11910        assert_eq!(
11911            source_state_dir_with_keying(&app_root, source, true),
11912            app_root.join("sources").join("api.contoso.test")
11913        );
11914        assert_eq!(
11915            source_state_dir_with_keying(&app_root, source, false),
11916            app_root.join("sources").join("contoso")
11917        );
11918
11919        let _ = fs::remove_dir_all(&app_root);
11920    }
11921
11922    #[test]
11923    fn source_metadata_and_named_reset_round_trip() {
11924        let app_root = temp_app_root("source-reset");
11925        let result = (|| -> Result<()> {
11926            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11927            repository.add_source_with_metadata(
11928                "test",
11929                "https://example.com/test",
11930                SourceKind::Rest,
11931                Some("trusted"),
11932                true,
11933                4,
11934            )?;
11935
11936            repository.edit_source("test", Some(false), None)?;
11937            let edited = repository
11938                .list_sources()
11939                .into_iter()
11940                .find(|source| source.name == "test")
11941                .expect("source");
11942            assert_eq!(edited.trust_level, "Trusted");
11943            assert!(!edited.explicit);
11944            assert_eq!(edited.priority, 4);
11945
11946            repository.reset_source("test")?;
11947            let reset = repository
11948                .list_sources()
11949                .into_iter()
11950                .find(|source| source.name == "test")
11951                .expect("source");
11952            assert_eq!(reset.trust_level, "Trusted");
11953            assert!(!reset.explicit);
11954            assert_eq!(reset.priority, 4);
11955            assert!(reset.last_update.is_none());
11956            assert!(reset.source_version.is_none());
11957            Ok(())
11958        })();
11959
11960        let _ = fs::remove_dir_all(&app_root);
11961        result.expect("source metadata");
11962    }
11963
11964    #[test]
11965    fn explicit_sources_are_skipped_by_default_search_resolution() {
11966        let app_root = temp_app_root("source-resolution");
11967        let result = (|| -> Result<()> {
11968            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11969            repository.add_source_with_metadata(
11970                "explicit-test",
11971                "https://example.com/explicit",
11972                SourceKind::Rest,
11973                Some("trusted"),
11974                true,
11975                10,
11976            )?;
11977
11978            let indexes = repository.resolve_source_indexes(None)?;
11979            let names: Vec<_> = indexes
11980                .into_iter()
11981                .map(|index| repository.list_sources()[index].name.clone())
11982                .collect();
11983            assert!(!names.iter().any(|name| name == "explicit-test"));
11984            Ok(())
11985        })();
11986
11987        let _ = fs::remove_dir_all(&app_root);
11988        result.expect("source resolution");
11989    }
11990
11991    #[test]
11992    fn source_resolution_accepts_source_identifier() {
11993        let app_root = temp_app_root("source-identifier-resolution");
11994        let result = (|| -> Result<()> {
11995            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11996            repository.add_source_with_metadata(
11997                "winget.pro",
11998                "https://api.winget.pro/feed",
11999                SourceKind::Rest,
12000                Some("trusted"),
12001                false,
12002                0,
12003            )?;
12004            repository.store.sources[0].identifier = "api.winget.pro".to_owned();
12005
12006            let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?;
12007            assert_eq!(indexes, vec![0]);
12008            Ok(())
12009        })();
12010
12011        let _ = fs::remove_dir_all(&app_root);
12012        result.expect("source identifier resolution");
12013    }
12014
12015    #[test]
12016    fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() {
12017        let query = PackageQuery {
12018            id: Some("tessl.tessl".to_owned()),
12019            source: Some("winget".to_owned()),
12020            exact: true,
12021            ..PackageQuery::default()
12022        };
12023        let clean_miss = SearchLocatedResult {
12024            matches: Vec::new(),
12025            warnings: Vec::new(),
12026            failures: Vec::new(),
12027            truncated: false,
12028        };
12029        assert!(should_retry_show_across_system_winget_sources(
12030            true,
12031            &query,
12032            &clean_miss
12033        ));
12034
12035        let private_mode = false;
12036        assert!(!should_retry_show_across_system_winget_sources(
12037            private_mode,
12038            &query,
12039            &clean_miss
12040        ));
12041
12042        let explicit_custom_source = PackageQuery {
12043            source: Some("winget.pro".to_owned()),
12044            ..query.clone()
12045        };
12046        assert!(!should_retry_show_across_system_winget_sources(
12047            true,
12048            &explicit_custom_source,
12049            &clean_miss
12050        ));
12051
12052        let failed_source = SearchLocatedResult {
12053            failures: vec![SourceSearchFailure {
12054                source_name: "winget".to_owned(),
12055                message: "network failure".to_owned(),
12056            }],
12057            ..clean_miss
12058        };
12059        assert!(!should_retry_show_across_system_winget_sources(
12060            true,
12061            &query,
12062            &failed_source
12063        ));
12064    }
12065
12066    #[test]
12067    fn user_and_admin_settings_round_trip() {
12068        let app_root = temp_app_root("settings");
12069        let result = (|| -> Result<()> {
12070            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
12071            repository.set_user_settings(
12072                &serde_json::json!({
12073                    "visual": {
12074                        "progressBar": "retro"
12075                    }
12076                }),
12077                false,
12078            )?;
12079            repository.set_user_settings(
12080                &serde_json::json!({
12081                    "experimentalFeatures": {
12082                        "directMSI": true
12083                    }
12084                }),
12085                true,
12086            )?;
12087
12088            let user_settings = repository.get_user_settings()?;
12089            assert_eq!(
12090                user_settings["visual"]["progressBar"],
12091                serde_json::Value::String("retro".to_owned())
12092            );
12093            assert_eq!(
12094                user_settings["experimentalFeatures"]["directMSI"],
12095                serde_json::Value::Bool(true)
12096            );
12097            assert!(repository.test_user_settings(
12098                &serde_json::json!({
12099                    "experimentalFeatures": {
12100                        "directMSI": true
12101                    }
12102                }),
12103                true
12104            )?);
12105
12106            repository.set_admin_setting("LocalManifestFiles", true)?;
12107            repository.set_admin_setting("InstallerHashOverride", true)?;
12108            let admin_settings = repository.get_admin_settings()?;
12109            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
12110            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
12111
12112            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
12113            let reset_one = repository.get_admin_settings()?;
12114            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
12115
12116            repository.reset_admin_setting(None, true)?;
12117            let reset_all = repository.get_admin_settings()?;
12118            for name in Repository::supported_admin_settings() {
12119                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
12120            }
12121            Ok(())
12122        })();
12123
12124        let _ = fs::remove_dir_all(&app_root);
12125        result.expect("settings");
12126    }
12127
12128    #[test]
12129    fn show_result_structured_document_is_manifest_oriented() {
12130        let result = ShowResult {
12131            package: SearchMatch {
12132                source_name: "winget".to_owned(),
12133                source_kind: SourceKind::PreIndexed,
12134                id: "Test.Package".to_owned(),
12135                name: "Test Package".to_owned(),
12136                moniker: Some("testpkg".to_owned()),
12137                version: Some("1.2.3".to_owned()),
12138                channel: Some("stable".to_owned()),
12139                match_criteria: Some("Id".to_owned()),
12140            },
12141            manifest: Manifest {
12142                id: "Test.Package".to_owned(),
12143                name: "Test Package".to_owned(),
12144                version: "1.2.3".to_owned(),
12145                channel: "stable".to_owned(),
12146                publisher: Some("Contoso".to_owned()),
12147                description: Some("Structured output".to_owned()),
12148                moniker: Some("testpkg".to_owned()),
12149                package_url: None,
12150                publisher_url: None,
12151                publisher_support_url: None,
12152                license: None,
12153                license_url: None,
12154                privacy_url: None,
12155                author: None,
12156                copyright: None,
12157                copyright_url: None,
12158                release_notes: None,
12159                release_notes_url: None,
12160                tags: vec!["utility".to_owned()],
12161                agreements: Vec::new(),
12162                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
12163                documentation: vec![Documentation {
12164                    label: Some("Docs".to_owned()),
12165                    url: "https://example.test/docs".to_owned(),
12166                }],
12167                icons: vec![PackageIcon {
12168                    icon_url: Some("https://example.test/default-256.ico".to_owned()),
12169                    icon_file_type: Some("ico".to_owned()),
12170                    icon_resolution: Some("256x256".to_owned()),
12171                    icon_theme: Some("default".to_owned()),
12172                    icon_sha256: Some("DEF456".to_owned()),
12173                }],
12174                installers: vec![Installer {
12175                    architecture: Some("x64".to_owned()),
12176                    installer_type: Some("msix".to_owned()),
12177                    url: Some("https://example.test/Test.Package.msix".to_owned()),
12178                    sha256: Some("ABC123".to_owned()),
12179                    product_code: None,
12180                    locale: Some("en-US".to_owned()),
12181                    scope: Some("machine".to_owned()),
12182                    release_date: None,
12183                    package_family_name: None,
12184                    upgrade_code: None,
12185                    platforms: Vec::new(),
12186                    minimum_os_version: None,
12187                    switches: InstallerSwitches {
12188                        silent: Some("/quiet".to_owned()),
12189                        ..InstallerSwitches::default()
12190                    },
12191                    commands: vec!["testpkg".to_owned()],
12192                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
12193                    require_explicit_upgrade: false,
12194                    nested_installer_type: None,
12195                    nested_installer_files: Vec::new(),
12196                }],
12197                require_explicit_upgrade: false,
12198            },
12199            selected_installer: Some(Installer {
12200                architecture: Some("x64".to_owned()),
12201                installer_type: Some("msix".to_owned()),
12202                url: Some("https://example.test/Test.Package.msix".to_owned()),
12203                sha256: Some("ABC123".to_owned()),
12204                product_code: None,
12205                locale: Some("en-US".to_owned()),
12206                scope: Some("machine".to_owned()),
12207                release_date: None,
12208                package_family_name: None,
12209                upgrade_code: None,
12210                platforms: Vec::new(),
12211                minimum_os_version: None,
12212                switches: InstallerSwitches {
12213                    silent: Some("/quiet".to_owned()),
12214                    ..InstallerSwitches::default()
12215                },
12216                commands: vec!["testpkg".to_owned()],
12217                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
12218                require_explicit_upgrade: false,
12219                nested_installer_type: None,
12220                nested_installer_files: Vec::new(),
12221            }),
12222            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
12223            warnings: vec!["cache warmed".to_owned()],
12224            manifest_documents: JsonValue::Array(vec![
12225                serde_json::json!({
12226                    "PackageIdentifier": "Test.Package",
12227                    "PackageVersion": "1.2.3",
12228                    "DefaultLocale": "en-US",
12229                    "ManifestType": "version",
12230                    "ManifestVersion": "1.10.0"
12231                }),
12232                serde_json::json!({
12233                    "PackageIdentifier": "Test.Package",
12234                    "PackageVersion": "1.2.3",
12235                    "PackageLocale": "en-US",
12236                    "PackageName": "Test Package",
12237                    "Publisher": "Example",
12238                    "License": "MIT",
12239                    "ShortDescription": "Structured output",
12240                    "Icons": [
12241                        {
12242                            "IconUrl": "https://example.test/default-256.ico",
12243                            "IconFileType": "ico",
12244                            "IconResolution": "256x256",
12245                            "IconTheme": "default",
12246                            "IconSha256": "DEF456"
12247                        }
12248                    ],
12249                    "ManifestType": "defaultLocale",
12250                    "ManifestVersion": "1.10.0"
12251                }),
12252                serde_json::json!({
12253                    "PackageIdentifier": "Test.Package",
12254                    "PackageVersion": "1.2.3",
12255                    "ManifestType": "installer",
12256                    "ManifestVersion": "1.10.0",
12257                    "Installers": [
12258                        {
12259                            "Architecture": "x64",
12260                            "InstallerType": "msix",
12261                            "InstallerUrl": "https://example.test/Test.Package.msix",
12262                            "InstallerSha256": "ABC123",
12263                            "Commands": ["testpkg"],
12264                            "InstallerSwitches": { "Silent": "/quiet" },
12265                            "Dependencies": {
12266                                "PackageDependencies": [
12267                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
12268                                ]
12269                            }
12270                        }
12271                    ]
12272                }),
12273            ]),
12274        };
12275
12276        let document = result.structured_document();
12277
12278        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
12279        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
12280        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
12281        assert_eq!(
12282            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
12283            Some("Microsoft.VCRedist.2015+.x64")
12284        );
12285        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
12286        assert_eq!(
12287            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
12288            Some("/quiet")
12289        );
12290        assert_eq!(
12291            document["IconUrl"].as_str(),
12292            Some("https://example.test/default-256.ico")
12293        );
12294        assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
12295        assert_eq!(
12296            document["Icons"][0]["IconUrl"].as_str(),
12297            Some("https://example.test/default-256.ico")
12298        );
12299    }
12300
12301    #[test]
12302    fn manifest_parses_require_explicit_upgrade_at_top_level() {
12303        // Top-level RequireExplicitUpgrade flag should propagate to
12304        // Manifest.require_explicit_upgrade. winget catalogs typically
12305        // place the flag here for browser packages and similar
12306        // self-updating apps that opt out of bulk `upgrade`.
12307        let yaml = r#"
12308PackageIdentifier: Test.Package
12309PackageVersion: 1.2.3
12310DefaultLocale: en-US
12311ManifestType: singleton
12312ManifestVersion: 1.10.0
12313PackageLocale: en-US
12314PackageName: Test Package
12315Publisher: Example
12316License: MIT
12317ShortDescription: explicit-upgrade fixture
12318RequireExplicitUpgrade: true
12319Installers:
12320  - Architecture: x64
12321    InstallerType: exe
12322    InstallerUrl: https://example.test/Test.Package.exe
12323    InstallerSha256: ABC123
12324"#;
12325        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12326        assert!(manifest.require_explicit_upgrade);
12327    }
12328
12329    #[test]
12330    fn manifest_parses_require_explicit_upgrade_on_installer() {
12331        // Per-installer flag — only one of several installers declares it,
12332        // but the Manifest aggregate must still be `true` because the user
12333        // could pick that installer when upgrading.
12334        let yaml = r#"
12335PackageIdentifier: Test.Package
12336PackageVersion: 1.2.3
12337DefaultLocale: en-US
12338ManifestType: singleton
12339ManifestVersion: 1.10.0
12340PackageLocale: en-US
12341PackageName: Test Package
12342Publisher: Example
12343License: MIT
12344ShortDescription: explicit-upgrade fixture
12345Installers:
12346  - Architecture: x64
12347    InstallerType: exe
12348    InstallerUrl: https://example.test/Test.Package.x64.exe
12349    InstallerSha256: ABC123
12350  - Architecture: arm64
12351    InstallerType: exe
12352    InstallerUrl: https://example.test/Test.Package.arm64.exe
12353    InstallerSha256: DEF456
12354    RequireExplicitUpgrade: true
12355"#;
12356        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12357        assert!(manifest.require_explicit_upgrade);
12358        assert!(!manifest.installers[0].require_explicit_upgrade);
12359        assert!(manifest.installers[1].require_explicit_upgrade);
12360    }
12361
12362    #[test]
12363    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
12364        let yaml = r#"
12365PackageIdentifier: Test.Package
12366PackageVersion: 1.2.3
12367DefaultLocale: en-US
12368ManifestType: singleton
12369ManifestVersion: 1.10.0
12370PackageLocale: en-US
12371PackageName: Test Package
12372Publisher: Example
12373License: MIT
12374ShortDescription: baseline fixture
12375Installers:
12376  - Architecture: x64
12377    InstallerType: exe
12378    InstallerUrl: https://example.test/Test.Package.exe
12379    InstallerSha256: ABC123
12380"#;
12381        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12382        assert!(!manifest.require_explicit_upgrade);
12383    }
12384
12385    #[test]
12386    fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
12387        let yaml = r#"
12388PackageIdentifier: Test.Package
12389PackageVersion: 1.2.3
12390DefaultLocale: en-US
12391ManifestType: singleton
12392ManifestVersion: 1.10.0
12393PackageLocale: en-US
12394PackageName: Test Package
12395Publisher: Example
12396License: MIT
12397ShortDescription: icon fixture
12398Icons:
12399  - IconUrl: https://example.test/dark-512.png
12400    IconFileType: png
12401    IconResolution: 512x512
12402    IconTheme: dark
12403    IconSha256: DARK
12404  - IconUrl: https://example.test/default-64.png
12405    IconFileType: png
12406    IconResolution: 64x64
12407    IconTheme: default
12408  - IconUrl: https://example.test/default-256.ico
12409    IconFileType: ico
12410    IconResolution: 256x256
12411    IconTheme: default
12412  - IconFileType: png
12413    IconResolution: 1024x1024
12414Installers:
12415  - Architecture: x64
12416    InstallerType: exe
12417    InstallerUrl: https://example.test/Test.Package.exe
12418    InstallerSha256: ABC123
12419"#;
12420        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12421
12422        assert_eq!(manifest.icons.len(), 4);
12423        assert_eq!(
12424            manifest.icons[0].icon_url.as_deref(),
12425            Some("https://example.test/dark-512.png")
12426        );
12427        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
12428        let selected = manifest.icon().expect("selected icon");
12429        assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
12430        assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
12431    }
12432
12433    #[test]
12434    fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
12435        let yaml = r#"
12436PackageIdentifier: Test.Package
12437PackageVersion: 1.2.3
12438DefaultLocale: en-US
12439ManifestType: singleton
12440ManifestVersion: 1.10.0
12441PackageLocale: en-US
12442PackageName: Test Package
12443Publisher: Example
12444License: MIT
12445ShortDescription: no-icon fixture
12446Installers:
12447  - Architecture: x64
12448    InstallerType: exe
12449    InstallerUrl: https://example.test/Test.Package.exe
12450    InstallerSha256: ABC123
12451"#;
12452        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12453
12454        assert!(manifest.icons.is_empty());
12455        assert!(manifest.icon().is_none());
12456        assert!(manifest.icon_url().is_none());
12457    }
12458
12459    #[test]
12460    fn rest_manifest_parses_icons_from_default_locale() {
12461        let response = serde_json::json!({
12462            "Data": {
12463                "PackageIdentifier": "Test.Package",
12464                "Versions": [
12465                    {
12466                        "PackageVersion": "1.2.3",
12467                        "DefaultLocale": {
12468                            "PackageName": "Test Package",
12469                            "Publisher": "Example",
12470                            "ShortDescription": "REST icon fixture",
12471                            "Icons": [
12472                                {
12473                                    "IconUrl": "https://example.test/default-128.png",
12474                                    "IconFileType": "png",
12475                                    "IconResolution": "128x128",
12476                                    "IconTheme": "default"
12477                                },
12478                                {
12479                                    "IconUrl": "https://example.test/default-256.ico",
12480                                    "IconFileType": "ico",
12481                                    "IconResolution": "256x256"
12482                                }
12483                            ]
12484                        },
12485                        "Installers": [
12486                            {
12487                                "Architecture": "x64",
12488                                "InstallerType": "exe",
12489                                "InstallerUrl": "https://example.test/Test.Package.exe",
12490                                "InstallerSha256": "ABC123"
12491                            }
12492                        ],
12493                        "ManifestVersion": "1.10.0"
12494                    }
12495                ],
12496                "ManifestVersion": "1.10.0"
12497            }
12498        });
12499        let bytes = serde_json::to_vec(&response).expect("json");
12500
12501        let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
12502
12503        assert_eq!(manifest.icons.len(), 2);
12504        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
12505        assert_eq!(
12506            document["Icons"][0]["IconUrl"].as_str(),
12507            Some("https://example.test/default-128.png")
12508        );
12509    }
12510
12511    #[test]
12512    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
12513        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
12514        // (Edge, Steam, Discord). pinget must do the same — the filter is
12515        // the only gate that enforces it.
12516        let mut pkg = InstalledPackage {
12517            name: "Edge".to_owned(),
12518            local_id: r"ARP\Machine\X64\Edge".to_owned(),
12519            installed_version: "100.0".to_owned(),
12520            publisher: None,
12521            scope: Some("Machine".to_owned()),
12522            installer_category: Some("exe".to_owned()),
12523            install_location: None,
12524            package_family_names: Vec::new(),
12525            product_codes: Vec::new(),
12526            upgrade_codes: Vec::new(),
12527            correlated: Some(SearchMatch {
12528                source_name: "winget".to_owned(),
12529                source_kind: SourceKind::PreIndexed,
12530                id: "Microsoft.Edge".to_owned(),
12531                name: "Microsoft Edge".to_owned(),
12532                moniker: None,
12533                version: Some("110.0".to_owned()),
12534                channel: None,
12535                match_criteria: None,
12536            }),
12537            winget_package_identifier: None,
12538            winget_source_identifier: None,
12539            installed_version_canonical: false,
12540            correlated_requires_explicit_upgrade: true,
12541            correlated_lacks_compatible_installer: false,
12542        };
12543        let bulk_query = ListQuery {
12544            upgrade_only: true,
12545            ..ListQuery::default()
12546        };
12547        assert!(
12548            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
12549            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
12550        );
12551
12552        // When the user explicitly targets it by id, winget shows it —
12553        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
12554        let filtered_query = ListQuery {
12555            upgrade_only: true,
12556            id: Some("Microsoft.Edge".to_owned()),
12557            ..ListQuery::default()
12558        };
12559        assert!(
12560            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
12561            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
12562        );
12563
12564        // Without the flag, the row appears in bulk upgrade as usual.
12565        pkg.correlated_requires_explicit_upgrade = false;
12566        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12567    }
12568
12569    #[test]
12570    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
12571        let mut pkg = InstalledPackage {
12572            name: "Foo".to_owned(),
12573            local_id: r"ARP\Machine\X64\Foo".to_owned(),
12574            installed_version: "1.0".to_owned(),
12575            publisher: None,
12576            scope: Some("Machine".to_owned()),
12577            installer_category: Some("exe".to_owned()),
12578            install_location: None,
12579            package_family_names: Vec::new(),
12580            product_codes: Vec::new(),
12581            upgrade_codes: Vec::new(),
12582            correlated: Some(SearchMatch {
12583                source_name: "winget".to_owned(),
12584                source_kind: SourceKind::PreIndexed,
12585                id: "Test.Foo".to_owned(),
12586                name: "Foo".to_owned(),
12587                moniker: None,
12588                version: Some("2.0".to_owned()),
12589                channel: None,
12590                match_criteria: None,
12591            }),
12592            winget_package_identifier: None,
12593            winget_source_identifier: None,
12594            installed_version_canonical: false,
12595            correlated_requires_explicit_upgrade: false,
12596            correlated_lacks_compatible_installer: true,
12597        };
12598        let bulk_query = ListQuery {
12599            upgrade_only: true,
12600            ..ListQuery::default()
12601        };
12602        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12603
12604        // Explicit `--id` still surfaces it.
12605        let filtered_query = ListQuery {
12606            upgrade_only: true,
12607            id: Some("Test.Foo".to_owned()),
12608            ..ListQuery::default()
12609        };
12610        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
12611
12612        // Cleared flag → visible in bulk.
12613        pkg.correlated_lacks_compatible_installer = false;
12614        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12615    }
12616
12617    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
12618        let installers = arches
12619            .iter()
12620            .map(|a| Installer {
12621                architecture: Some((*a).to_owned()),
12622                installer_type: None,
12623                url: None,
12624                sha256: None,
12625                product_code: None,
12626                locale: None,
12627                scope: None,
12628                release_date: None,
12629                package_family_name: None,
12630                upgrade_code: None,
12631                platforms: Vec::new(),
12632                minimum_os_version: None,
12633                switches: InstallerSwitches::default(),
12634                commands: Vec::new(),
12635                package_dependencies: Vec::new(),
12636                require_explicit_upgrade: false,
12637                nested_installer_type: None,
12638                nested_installer_files: Vec::new(),
12639            })
12640            .collect();
12641        Manifest {
12642            id: "Test".to_owned(),
12643            name: "Test".to_owned(),
12644            version: "1.0".to_owned(),
12645            channel: String::new(),
12646            publisher: None,
12647            description: None,
12648            moniker: None,
12649            package_url: None,
12650            publisher_url: None,
12651            publisher_support_url: None,
12652            license: None,
12653            license_url: None,
12654            privacy_url: None,
12655            author: None,
12656            copyright: None,
12657            copyright_url: None,
12658            release_notes: None,
12659            release_notes_url: None,
12660            tags: Vec::new(),
12661            agreements: Vec::new(),
12662            package_dependencies: Vec::new(),
12663            documentation: Vec::new(),
12664            icons: Vec::new(),
12665            installers,
12666            require_explicit_upgrade: false,
12667        }
12668    }
12669
12670    #[test]
12671    fn manifest_compatible_installer_neutral_always_matches() {
12672        // `neutral` is in every host's allowed list.
12673        assert!(manifest_has_compatible_installer(
12674            &synthetic_manifest_with_installer_arches(&["neutral"])
12675        ));
12676    }
12677
12678    #[test]
12679    fn manifest_compatible_installer_empty_installers_pass_through() {
12680        // Manifest with no installers (e.g. a docs-only entry): nothing to
12681        // filter against, don't hide the row.
12682        assert!(manifest_has_compatible_installer(
12683            &synthetic_manifest_with_installer_arches(&[])
12684        ));
12685    }
12686
12687    #[test]
12688    fn manifest_compatible_installer_rejects_alien_arch_only() {
12689        // `ppc` is in no host's allowed list — manifest has nothing runnable.
12690        assert!(!manifest_has_compatible_installer(
12691            &synthetic_manifest_with_installer_arches(&["ppc"])
12692        ));
12693    }
12694
12695    #[test]
12696    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
12697        // Mixed list where at least `neutral` is in every host's preference.
12698        assert!(manifest_has_compatible_installer(
12699            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
12700        ));
12701    }
12702
12703    #[test]
12704    fn lookup_unique_normalized_identity_returns_unique_match() {
12705        // Single (norm_name, norm_publisher) intersection — happy path.
12706        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12707        execute_batch_sql(
12708            &connection,
12709            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12710             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12711             INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
12712             INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
12713        )
12714        .expect("seed schema");
12715
12716        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
12717            .expect("query")
12718            .expect("unique match");
12719        assert_eq!(rowid, 100);
12720    }
12721
12722    #[test]
12723    fn lookup_installed_db_identity_match_returns_product_code_match() {
12724        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12725        execute_batch_sql(
12726            &connection,
12727            "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\
12728             CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\
12729             CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\
12730             CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\
12731             CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\
12732             INSERT INTO ids VALUES (1, 'Git.Git');\n\
12733             INSERT INTO names VALUES (1, 'Git');\n\
12734             INSERT INTO manifest VALUES (10, 1, 1);\n\
12735             INSERT INTO productcodes VALUES (20, 'git_is1');\n\
12736             INSERT INTO productcodes_map VALUES (10, 20);",
12737        )
12738        .expect("seed schema");
12739        let package = InstalledPackage {
12740            name: "Git".to_owned(),
12741            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
12742            installed_version: "2.54.0".to_owned(),
12743            publisher: None,
12744            scope: None,
12745            installer_category: None,
12746            install_location: None,
12747            package_family_names: Vec::new(),
12748            product_codes: vec!["git_is1".to_owned()],
12749            upgrade_codes: Vec::new(),
12750            correlated: None,
12751            winget_package_identifier: None,
12752            winget_source_identifier: None,
12753            installed_version_canonical: false,
12754            correlated_requires_explicit_upgrade: false,
12755            correlated_lacks_compatible_installer: false,
12756        };
12757
12758        let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package)
12759            .expect("query")
12760            .expect("product code match");
12761
12762        assert_eq!(id, "Git.Git");
12763        assert_eq!(name, "Git");
12764        assert_eq!(matched_by, "ProductCode");
12765    }
12766
12767    #[test]
12768    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
12769        // Two distinct packages share the same (norm_name, norm_publisher)
12770        // — the Git case where both `Git.Git` and a hypothetical sibling
12771        // normalize identically. winget refuses to correlate when it can't
12772        // disambiguate; pinget must do the same.
12773        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12774        execute_batch_sql(
12775            &connection,
12776            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12777             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12778             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12779             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
12780        )
12781        .expect("seed schema");
12782
12783        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12784        assert!(rowid.is_none(), "ambiguous match must not correlate");
12785    }
12786
12787    #[test]
12788    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
12789        // norm_name has multiple matches but only one shares its
12790        // norm_publisher with the installed package. Winget's intersect
12791        // logic still picks the right one — verify.
12792        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12793        execute_batch_sql(
12794            &connection,
12795            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12796             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12797             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12798             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
12799        )
12800        .expect("seed schema");
12801
12802        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12803        assert_eq!(rowid, Some(100));
12804    }
12805
12806    #[test]
12807    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
12808        // Name matches but no publisher row for that package id —
12809        // intersection is empty so we don't correlate.
12810        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12811        execute_batch_sql(
12812            &connection,
12813            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12814             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12815             INSERT INTO norm_names2 VALUES ('foo', 100);\n\
12816             INSERT INTO norm_publishers2 VALUES ('bar', 200);",
12817        )
12818        .expect("seed schema");
12819
12820        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
12821        assert!(
12822            rowid.is_none(),
12823            "name matches package 100 but publisher matches 200 — no intersection"
12824        );
12825    }
12826
12827    #[test]
12828    fn parse_yaml_manifest_bundle_returns_singleton_document() {
12829        let yaml = r#"
12830PackageIdentifier: Test.Package
12831PackageVersion: 1.2.3
12832DefaultLocale: en-US
12833ManifestType: version
12834ManifestVersion: 1.10.0
12835---
12836PackageIdentifier: Test.Package
12837PackageVersion: 1.2.3
12838PackageLocale: en-US
12839PackageName: Test Package
12840Publisher: Example
12841License: MIT
12842ShortDescription: Structured output
12843ManifestType: defaultLocale
12844ManifestVersion: 1.10.0
12845---
12846PackageIdentifier: Test.Package
12847PackageVersion: 1.2.3
12848ManifestType: installer
12849ManifestVersion: 1.10.0
12850Installers:
12851  - Architecture: x64
12852    InstallerType: exe
12853    InstallerUrl: https://example.test/Test.Package.exe
12854    InstallerSha256: ABC123
12855"#;
12856
12857        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
12858
12859        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
12860        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
12861        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
12862    }
12863
12864    #[test]
12865    fn collapse_structured_documents_returns_plural_show_documents() {
12866        let documents = collapse_structured_documents(&[
12867            JsonValue::Array(vec![
12868                serde_json::json!({
12869                    "PackageIdentifier": "Test.Package.One",
12870                    "PackageVersion": "1.0.0",
12871                    "DefaultLocale": "en-US",
12872                    "ManifestType": "version",
12873                    "ManifestVersion": "1.10.0"
12874                }),
12875                serde_json::json!({
12876                    "PackageIdentifier": "Test.Package.One",
12877                    "PackageVersion": "1.0.0",
12878                    "PackageLocale": "en-US",
12879                    "PackageName": "Test Package One",
12880                    "ManifestType": "defaultLocale",
12881                    "ManifestVersion": "1.10.0"
12882                }),
12883                serde_json::json!({
12884                    "PackageIdentifier": "Test.Package.One",
12885                    "PackageVersion": "1.0.0",
12886                    "ManifestType": "installer",
12887                    "ManifestVersion": "1.10.0",
12888                    "Installers": [
12889                        {
12890                            "Architecture": "x64",
12891                            "InstallerType": "exe",
12892                            "InstallerUrl": "https://example.test/one.exe",
12893                            "InstallerSha256": "ABC123"
12894                        }
12895                    ]
12896                }),
12897            ]),
12898            serde_json::json!({
12899                "PackageIdentifier": "Test.Package.Two",
12900                "PackageVersion": "2.0.0",
12901                "PackageLocale": "en-US",
12902                "PackageName": "Test Package Two",
12903                "ManifestType": "singleton",
12904                "ManifestVersion": "1.12.0"
12905            }),
12906        ]);
12907
12908        assert_eq!(documents.len(), 2);
12909        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
12910        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
12911        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
12912        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
12913        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
12914    }
12915
12916    #[test]
12917    fn unsupported_action_result_marks_noop_and_warning() {
12918        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
12919
12920        assert!(result.success);
12921        assert!(result.no_op);
12922        assert_eq!(result.exit_code, 0);
12923        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
12924    }
12925
12926    #[test]
12927    fn create_install_no_op_result_honors_no_upgrade() {
12928        let mut request = InstallRequest::new(PackageQuery {
12929            id: Some("Contoso.App".to_owned()),
12930            ..PackageQuery::default()
12931        });
12932        request.no_upgrade = true;
12933
12934        let manifest = Manifest {
12935            id: "Contoso.App".to_owned(),
12936            name: "Contoso App".to_owned(),
12937            version: "2.0.0".to_owned(),
12938            channel: String::new(),
12939            publisher: None,
12940            description: None,
12941            moniker: None,
12942            package_url: None,
12943            publisher_url: None,
12944            publisher_support_url: None,
12945            license: None,
12946            license_url: None,
12947            privacy_url: None,
12948            author: None,
12949            copyright: None,
12950            copyright_url: None,
12951            release_notes: None,
12952            release_notes_url: None,
12953            tags: Vec::new(),
12954            agreements: Vec::new(),
12955            package_dependencies: Vec::new(),
12956            documentation: Vec::new(),
12957            icons: Vec::new(),
12958            installers: Vec::new(),
12959            require_explicit_upgrade: false,
12960        };
12961        let existing = ListMatch {
12962            name: "Contoso App".to_owned(),
12963            id: "Contoso.App".to_owned(),
12964            local_id: "Contoso.App".to_owned(),
12965            installed_version: "1.0.0".to_owned(),
12966            available_version: Some("2.0.0".to_owned()),
12967            source_name: Some("winget".to_owned()),
12968            publisher: None,
12969            scope: None,
12970            installer_category: None,
12971            install_location: None,
12972            package_family_names: Vec::new(),
12973            product_codes: Vec::new(),
12974            upgrade_codes: Vec::new(),
12975        };
12976
12977        let result =
12978            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12979
12980        assert!(result.success);
12981        assert!(result.no_op);
12982        assert_eq!(result.version, "1.0.0");
12983        assert_eq!(
12984            result.warnings,
12985            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
12986        );
12987    }
12988
12989    #[test]
12990    fn create_install_no_op_result_skips_reinstall_when_current() {
12991        let request = InstallRequest::new(PackageQuery {
12992            id: Some("Contoso.App".to_owned()),
12993            ..PackageQuery::default()
12994        });
12995
12996        let manifest = Manifest {
12997            id: "Contoso.App".to_owned(),
12998            name: "Contoso App".to_owned(),
12999            version: "2.0.0".to_owned(),
13000            channel: String::new(),
13001            publisher: None,
13002            description: None,
13003            moniker: None,
13004            package_url: None,
13005            publisher_url: None,
13006            publisher_support_url: None,
13007            license: None,
13008            license_url: None,
13009            privacy_url: None,
13010            author: None,
13011            copyright: None,
13012            copyright_url: None,
13013            release_notes: None,
13014            release_notes_url: None,
13015            tags: Vec::new(),
13016            agreements: Vec::new(),
13017            package_dependencies: Vec::new(),
13018            documentation: Vec::new(),
13019            icons: Vec::new(),
13020            installers: Vec::new(),
13021            require_explicit_upgrade: false,
13022        };
13023        let existing = ListMatch {
13024            name: "Contoso App".to_owned(),
13025            id: "Contoso.App".to_owned(),
13026            local_id: "Contoso.App".to_owned(),
13027            installed_version: "2.0.0".to_owned(),
13028            available_version: Some("2.0.0".to_owned()),
13029            source_name: Some("winget".to_owned()),
13030            publisher: None,
13031            scope: None,
13032            installer_category: None,
13033            install_location: None,
13034            package_family_names: Vec::new(),
13035            product_codes: Vec::new(),
13036            upgrade_codes: Vec::new(),
13037        };
13038
13039        let result =
13040            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
13041
13042        assert!(result.success);
13043        assert!(result.no_op);
13044        assert_eq!(result.version, "2.0.0");
13045        assert_eq!(
13046            result.warnings,
13047            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
13048        );
13049    }
13050
13051    #[test]
13052    fn collapse_structured_document_projects_merged_manifest() {
13053        let document = collapse_structured_document(&serde_json::json!({
13054            "PackageIdentifier": "Test.Package",
13055            "PackageVersion": "1.2.3",
13056            "PackageLocale": "en-US",
13057            "PackageName": "Test Package",
13058            "Publisher": "Example",
13059            "InstallerType": "exe",
13060            "Installers": [
13061                {
13062                    "Architecture": "x64",
13063                    "InstallerUrl": "https://example.test/Test.Package.exe",
13064                    "InstallerSha256": "ABC123"
13065                }
13066            ],
13067            "ManifestType": "merged",
13068            "ManifestVersion": "1.10.0"
13069        }));
13070
13071        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
13072        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
13073        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
13074        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
13075        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
13076    }
13077
13078    #[test]
13079    fn parses_fixture_manifest() {
13080        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
13081            .join("tests")
13082            .join("fixtures")
13083            .join("ManifestV1_28-Singleton.yaml");
13084        let bytes = fs::read(root).expect("fixture bytes");
13085        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
13086        assert_eq!(manifest.id, "microsoft.msixsdk");
13087        assert_eq!(manifest.version, "1.7.32");
13088        assert_eq!(manifest.name, "MSIX SDK");
13089        assert!(!manifest.installers.is_empty());
13090    }
13091
13092    #[test]
13093    fn parses_installer_switches_from_yaml_manifest() {
13094        let yaml = r#"
13095PackageIdentifier: Test.Package
13096PackageVersion: 1.2.3
13097PackageName: Test Package
13098InstallerSwitches:
13099  SilentWithProgress: /SILENT
13100Installers:
13101  - Architecture: x64
13102    InstallerType: inno
13103    InstallerUrl: https://example.test/Test.Package.exe
13104    InstallerSha256: ABC123
13105    InstallerSwitches:
13106      Silent: /VERYSILENT
13107      Interactive: /HELP
13108"#;
13109
13110        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
13111        let installer = &manifest.installers[0];
13112
13113        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
13114        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
13115        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
13116    }
13117
13118    #[test]
13119    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
13120        let yaml = r#"
13121PackageIdentifier: Test.Package
13122PackageVersion: 1.2.3
13123PackageName: Test Package
13124Platform:
13125  - Windows.Desktop
13126MinimumOSVersion: 10.0.19041.0
13127Installers:
13128  - Architecture: x64
13129    InstallerType: exe
13130    InstallerUrl: https://example.test/Test.Package.exe
13131    InstallerSha256: ABC123
13132  - Architecture: x64
13133    InstallerType: msix
13134    Platform:
13135      - Windows.Universal
13136    MinimumOSVersion: 10.0.22621.0
13137    InstallerUrl: https://example.test/Test.Package.msix
13138    InstallerSha256: DEF456
13139"#;
13140
13141        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
13142
13143        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
13144        assert_eq!(
13145            manifest.installers[0].minimum_os_version.as_deref(),
13146            Some("10.0.19041.0")
13147        );
13148        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
13149        assert_eq!(
13150            manifest.installers[1].minimum_os_version.as_deref(),
13151            Some("10.0.22621.0")
13152        );
13153    }
13154
13155    #[test]
13156    fn installer_switch_arguments_prefer_manifest_switches() {
13157        let installer = Installer {
13158            architecture: None,
13159            installer_type: Some("inno".to_owned()),
13160            url: None,
13161            sha256: None,
13162            product_code: None,
13163            locale: None,
13164            scope: None,
13165            release_date: None,
13166            package_family_name: None,
13167            upgrade_code: None,
13168            platforms: Vec::new(),
13169            minimum_os_version: None,
13170            switches: InstallerSwitches {
13171                silent: Some("/mysilent".to_owned()),
13172                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
13173                interactive: Some("/myinteractive".to_owned()),
13174                ..InstallerSwitches::default()
13175            },
13176            commands: Vec::new(),
13177            package_dependencies: Vec::new(),
13178            require_explicit_upgrade: false,
13179            nested_installer_type: None,
13180            nested_installer_files: Vec::new(),
13181        };
13182        let manifest = Manifest {
13183            id: "Test.Package".to_owned(),
13184            name: "Test Package".to_owned(),
13185            version: "1.0.0".to_owned(),
13186            channel: String::new(),
13187            publisher: None,
13188            description: None,
13189            moniker: None,
13190            package_url: None,
13191            publisher_url: None,
13192            publisher_support_url: None,
13193            license: None,
13194            license_url: None,
13195            privacy_url: None,
13196            author: None,
13197            copyright: None,
13198            copyright_url: None,
13199            release_notes: None,
13200            release_notes_url: None,
13201            tags: Vec::new(),
13202            agreements: Vec::new(),
13203            package_dependencies: Vec::new(),
13204            documentation: Vec::new(),
13205            icons: Vec::new(),
13206            installers: Vec::new(),
13207            require_explicit_upgrade: false,
13208        };
13209
13210        let mut silent_request = InstallRequest::new(PackageQuery::default());
13211        silent_request.mode = InstallerMode::Silent;
13212        let mut progress_request = InstallRequest::new(PackageQuery::default());
13213        progress_request.mode = InstallerMode::SilentWithProgress;
13214        let mut interactive_request = InstallRequest::new(PackageQuery::default());
13215        interactive_request.mode = InstallerMode::Interactive;
13216        assert_eq!(
13217            installer_command_arguments(
13218                "inno",
13219                &silent_request,
13220                &manifest,
13221                Path::new("installer.exe"),
13222                &installer
13223            ),
13224            vec!["/mysilent".to_owned()]
13225        );
13226        assert_eq!(
13227            installer_command_arguments(
13228                "inno",
13229                &progress_request,
13230                &manifest,
13231                Path::new("installer.exe"),
13232                &installer
13233            ),
13234            vec!["/mysilentwithprogress".to_owned()]
13235        );
13236        assert_eq!(
13237            installer_command_arguments(
13238                "inno",
13239                &interactive_request,
13240                &manifest,
13241                Path::new("installer.exe"),
13242                &installer
13243            ),
13244            vec!["/myinteractive".to_owned()]
13245        );
13246    }
13247
13248    #[test]
13249    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
13250        let installer = Installer {
13251            architecture: None,
13252            installer_type: Some("inno".to_owned()),
13253            url: None,
13254            sha256: None,
13255            product_code: None,
13256            locale: None,
13257            scope: None,
13258            release_date: None,
13259            package_family_name: None,
13260            upgrade_code: None,
13261            platforms: Vec::new(),
13262            minimum_os_version: None,
13263            switches: InstallerSwitches::default(),
13264            commands: Vec::new(),
13265            package_dependencies: Vec::new(),
13266            require_explicit_upgrade: false,
13267            nested_installer_type: None,
13268            nested_installer_files: Vec::new(),
13269        };
13270        let manifest = Manifest {
13271            id: "Test.Package".to_owned(),
13272            name: "Test Package".to_owned(),
13273            version: "1.0.0".to_owned(),
13274            channel: String::new(),
13275            publisher: None,
13276            description: None,
13277            moniker: None,
13278            package_url: None,
13279            publisher_url: None,
13280            publisher_support_url: None,
13281            license: None,
13282            license_url: None,
13283            privacy_url: None,
13284            author: None,
13285            copyright: None,
13286            copyright_url: None,
13287            release_notes: None,
13288            release_notes_url: None,
13289            tags: Vec::new(),
13290            agreements: Vec::new(),
13291            package_dependencies: Vec::new(),
13292            documentation: Vec::new(),
13293            icons: Vec::new(),
13294            installers: Vec::new(),
13295            require_explicit_upgrade: false,
13296        };
13297        let mut progress_request = InstallRequest::new(PackageQuery::default());
13298        progress_request.mode = InstallerMode::SilentWithProgress;
13299        let mut silent_request = InstallRequest::new(PackageQuery::default());
13300        silent_request.mode = InstallerMode::Silent;
13301
13302        assert_eq!(
13303            installer_command_arguments(
13304                "inno",
13305                &progress_request,
13306                &manifest,
13307                Path::new("installer.exe"),
13308                &installer
13309            ),
13310            vec![
13311                "/SP-".to_owned(),
13312                "/SILENT".to_owned(),
13313                "/SUPPRESSMSGBOXES".to_owned(),
13314                "/NORESTART".to_owned()
13315            ]
13316        );
13317        assert_eq!(
13318            installer_command_arguments(
13319                "inno",
13320                &silent_request,
13321                &manifest,
13322                Path::new("installer.exe"),
13323                &installer
13324            ),
13325            vec![
13326                "/SP-".to_owned(),
13327                "/VERYSILENT".to_owned(),
13328                "/SUPPRESSMSGBOXES".to_owned(),
13329                "/NORESTART".to_owned()
13330            ]
13331        );
13332    }
13333
13334    #[test]
13335    fn installer_command_arguments_append_manifest_and_cli_switches() {
13336        let installer = Installer {
13337            architecture: None,
13338            installer_type: Some("msi".to_owned()),
13339            url: None,
13340            sha256: None,
13341            product_code: None,
13342            locale: None,
13343            scope: None,
13344            release_date: None,
13345            package_family_name: None,
13346            upgrade_code: None,
13347            platforms: Vec::new(),
13348            minimum_os_version: None,
13349            switches: InstallerSwitches {
13350                custom: Some("ADDLOCAL=Core".to_owned()),
13351                log: Some("/log \"<LOGPATH>\"".to_owned()),
13352                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
13353                ..InstallerSwitches::default()
13354            },
13355            commands: Vec::new(),
13356            package_dependencies: Vec::new(),
13357            require_explicit_upgrade: false,
13358            nested_installer_type: None,
13359            nested_installer_files: Vec::new(),
13360        };
13361        let manifest = Manifest {
13362            id: "ShareX.ShareX".to_owned(),
13363            name: "ShareX".to_owned(),
13364            version: "19.0.2".to_owned(),
13365            channel: String::new(),
13366            publisher: None,
13367            description: None,
13368            moniker: None,
13369            package_url: None,
13370            publisher_url: None,
13371            publisher_support_url: None,
13372            license: None,
13373            license_url: None,
13374            privacy_url: None,
13375            author: None,
13376            copyright: None,
13377            copyright_url: None,
13378            release_notes: None,
13379            release_notes_url: None,
13380            tags: Vec::new(),
13381            agreements: Vec::new(),
13382            package_dependencies: Vec::new(),
13383            documentation: Vec::new(),
13384            icons: Vec::new(),
13385            installers: Vec::new(),
13386            require_explicit_upgrade: false,
13387        };
13388        let mut request = InstallRequest::new(PackageQuery::default());
13389        request.mode = InstallerMode::Silent;
13390        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
13391        request.custom = Some("REBOOT=ReallySuppress".to_owned());
13392        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
13393
13394        assert_eq!(
13395            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
13396            vec![
13397                "/i".to_owned(),
13398                r"C:\temp\ShareX.msi".to_owned(),
13399                "/quiet".to_owned(),
13400                "/norestart".to_owned(),
13401                "/log".to_owned(),
13402                r"C:\temp\winget.log".to_owned(),
13403                "ADDLOCAL=Core".to_owned(),
13404                "REBOOT=ReallySuppress".to_owned(),
13405                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
13406            ]
13407        );
13408    }
13409
13410    #[test]
13411    fn msi_shell_execute_arguments_include_msiexec_target() {
13412        let installer = Installer {
13413            architecture: None,
13414            installer_type: Some("wix".to_owned()),
13415            url: None,
13416            sha256: None,
13417            product_code: None,
13418            locale: None,
13419            scope: None,
13420            release_date: None,
13421            package_family_name: None,
13422            upgrade_code: None,
13423            platforms: Vec::new(),
13424            minimum_os_version: None,
13425            switches: InstallerSwitches {
13426                log: Some("/log \"<LOGPATH>\"".to_owned()),
13427                install_location: Some("INSTALLDIR=\"<INSTALLPATH>\"".to_owned()),
13428                ..InstallerSwitches::default()
13429            },
13430            commands: Vec::new(),
13431            package_dependencies: Vec::new(),
13432            require_explicit_upgrade: false,
13433            nested_installer_type: None,
13434            nested_installer_files: Vec::new(),
13435        };
13436        let mut request = InstallRequest::new(PackageQuery::default());
13437        request.mode = InstallerMode::Silent;
13438        request.log_path = Some(PathBuf::from(r"C:\temp\node.log"));
13439        request.install_location = Some(r"C:\Program Files\nodejs".to_owned());
13440
13441        assert_eq!(
13442            installer_shell_execute_arguments(
13443                "wix",
13444                &request,
13445                &Manifest {
13446                    id: "OpenJS.NodeJS.22".to_owned(),
13447                    name: "Node.js".to_owned(),
13448                    version: "22.22.3".to_owned(),
13449                    channel: String::new(),
13450                    publisher: None,
13451                    description: None,
13452                    moniker: None,
13453                    package_url: None,
13454                    publisher_url: None,
13455                    publisher_support_url: None,
13456                    license: None,
13457                    license_url: None,
13458                    privacy_url: None,
13459                    author: None,
13460                    copyright: None,
13461                    copyright_url: None,
13462                    release_notes: None,
13463                    release_notes_url: None,
13464                    tags: Vec::new(),
13465                    agreements: Vec::new(),
13466                    package_dependencies: Vec::new(),
13467                    documentation: Vec::new(),
13468                    icons: Vec::new(),
13469                    installers: Vec::new(),
13470                    require_explicit_upgrade: false,
13471                },
13472                Path::new(r"C:\temp\node.msi"),
13473                &installer
13474            ),
13475            vec![
13476                "/i".to_owned(),
13477                r"C:\temp\node.msi".to_owned(),
13478                "/quiet".to_owned(),
13479                "/norestart".to_owned(),
13480                "/log".to_owned(),
13481                r"C:\temp\node.log".to_owned(),
13482                r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13483            ]
13484        );
13485    }
13486
13487    #[test]
13488    fn msi_direct_arguments_omit_msiexec_target() {
13489        let installer = Installer::default();
13490        let mut request = InstallRequest::new(PackageQuery::default());
13491        request.mode = InstallerMode::Silent;
13492
13493        assert_eq!(
13494            installer_direct_msi_arguments(
13495                "wix",
13496                &request,
13497                &Manifest {
13498                    id: "Test.Package".to_owned(),
13499                    name: "Test".to_owned(),
13500                    version: "1.0.0".to_owned(),
13501                    channel: String::new(),
13502                    publisher: None,
13503                    description: None,
13504                    moniker: None,
13505                    package_url: None,
13506                    publisher_url: None,
13507                    publisher_support_url: None,
13508                    license: None,
13509                    license_url: None,
13510                    privacy_url: None,
13511                    author: None,
13512                    copyright: None,
13513                    copyright_url: None,
13514                    release_notes: None,
13515                    release_notes_url: None,
13516                    tags: Vec::new(),
13517                    agreements: Vec::new(),
13518                    package_dependencies: Vec::new(),
13519                    documentation: Vec::new(),
13520                    icons: Vec::new(),
13521                    installers: Vec::new(),
13522                    require_explicit_upgrade: false,
13523                },
13524                Path::new(r"C:\temp\test.msi"),
13525                &installer
13526            ),
13527            vec!["/quiet".to_owned(), "/norestart".to_owned()]
13528        );
13529    }
13530
13531    #[test]
13532    fn msi_direct_arguments_enable_uac_only_for_quiet_ui() {
13533        let args = vec![
13534            "/quiet".to_owned(),
13535            "/norestart".to_owned(),
13536            "/log".to_owned(),
13537            r"C:\temp\node.log".to_owned(),
13538            r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13539        ];
13540
13541        let parsed = parse_msi_direct_arguments(&args).expect("parsed");
13542
13543        assert_eq!(parsed.ui_level, 0x102);
13544        assert_eq!(
13545            parsed.properties,
13546            r#"REBOOT=ReallySuppress INSTALLDIR="C:\Program Files\nodejs""#
13547        );
13548        assert_eq!(parsed.log_path.as_deref(), Some(r"C:\temp\node.log"));
13549    }
13550
13551    #[cfg(windows)]
13552    #[test]
13553    fn machine_scope_silent_msi_uses_shell_execute_instead_of_direct_msi() {
13554        let mut request = InstallRequest::new(PackageQuery::default());
13555        request.mode = InstallerMode::Silent;
13556
13557        let installer = Installer {
13558            installer_type: Some("wix".to_owned()),
13559            scope: Some("machine".to_owned()),
13560            ..Installer::default()
13561        };
13562
13563        assert!(!should_run_msi_direct(&request, &installer));
13564        assert!(should_elevate_msi_shell_execute(&installer));
13565    }
13566
13567    #[test]
13568    fn installer_download_if_range_prefers_strong_etag_and_rejects_weak_etag() {
13569        let strong = InstallerDownloadMetadata {
13570            url: "https://example.test/app.exe".to_owned(),
13571            etag: Some(r#""abc""#.to_owned()),
13572            last_modified: Some("Wed, 03 Jun 2026 21:00:00 GMT".to_owned()),
13573            total_length: Some(100),
13574            offset: 50,
13575        };
13576        let weak = InstallerDownloadMetadata {
13577            etag: Some(r#"W/"abc""#.to_owned()),
13578            ..strong.clone()
13579        };
13580
13581        assert_eq!(installer_download_if_range(&strong).as_deref(), Some(r#""abc""#));
13582        assert_eq!(
13583            installer_download_if_range(&weak).as_deref(),
13584            Some("Wed, 03 Jun 2026 21:00:00 GMT")
13585        );
13586    }
13587
13588    #[test]
13589    fn parse_content_range_validates_resume_start_and_total() {
13590        let value = HeaderValue::from_static("bytes 50-99/100");
13591        let parsed = parse_content_range(Some(&value), 50).expect("content range");
13592
13593        assert_eq!(parsed.total_length, Some(100));
13594        assert!(parse_content_range(Some(&value), 49).is_err());
13595    }
13596
13597    #[test]
13598    fn installer_download_sidecar_paths_append_suffixes() {
13599        let path = Path::new(r"C:\temp\installer.msi");
13600
13601        assert_eq!(sidecar_path(path, "part"), PathBuf::from(r"C:\temp\installer.msi.part"));
13602        assert_eq!(
13603            sidecar_path(path, "part.json"),
13604            PathBuf::from(r"C:\temp\installer.msi.part.json")
13605        );
13606    }
13607
13608    #[test]
13609    fn shell_execute_argument_joining_quotes_spaces() {
13610        assert_eq!(
13611            join_windows_arguments(&[
13612                "/quiet".to_owned(),
13613                r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13614                r#"VALUE=has "quotes""#.to_owned(),
13615            ]),
13616            r#"/quiet "INSTALLDIR=C:\Program Files\nodejs" "VALUE=has \"quotes\"""#
13617        );
13618    }
13619
13620    #[test]
13621    fn decompresses_mszyml_payload() {
13622        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
13623        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13624        encoder.write_all(payload.as_bytes()).expect("write payload");
13625        let compressed = encoder.finish().expect("finish payload");
13626        let mut mszip = b"CK".to_vec();
13627        mszip.extend_from_slice(&compressed);
13628
13629        let decompressed = decompress_mszyml(&mszip).expect("decompress");
13630        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13631        assert_eq!(parsed.versions[0].version, "1.2.3");
13632    }
13633
13634    #[test]
13635    fn decompresses_mszyml_payload_with_prefix() {
13636        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
13637        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13638        encoder.write_all(payload.as_bytes()).expect("write payload");
13639        let compressed = encoder.finish().expect("finish payload");
13640        let mut prefixed = vec![0u8; 28];
13641        prefixed.extend_from_slice(b"CK");
13642        prefixed.extend_from_slice(&compressed);
13643
13644        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
13645        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13646        assert_eq!(parsed.versions[0].version, "1.2.3");
13647    }
13648
13649    #[test]
13650    fn compares_versions_naturally() {
13651        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
13652        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
13653        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
13654        assert_eq!(compare_version("25.4.1", "< 25.4.1"), Ordering::Greater);
13655        assert_eq!(compare_version("< 25.4.1", "25.4.1"), Ordering::Less);
13656    }
13657
13658    #[test]
13659    fn installer_hash_verification_fails_on_mismatch_without_override() {
13660        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
13661        assert!(result.is_err());
13662        assert!(
13663            result
13664                .expect_err("hash mismatch")
13665                .to_string()
13666                .contains("Installer hash mismatch")
13667        );
13668    }
13669
13670    #[test]
13671    fn installer_hash_verification_allows_mismatch_with_override() {
13672        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
13673        verify_installer_hash(None, b"pinget", false).expect("missing hash");
13674    }
13675
13676    #[test]
13677    fn search_query_many_includes_tag_and_command_conditions() {
13678        let query = PackageQuery {
13679            query: Some("terminal".to_owned()),
13680            ..PackageQuery::default()
13681        };
13682
13683        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13684
13685        assert!(where_clause.contains("tags2"));
13686        assert!(where_clause.contains("commands2"));
13687        assert_eq!(params.len(), 5);
13688    }
13689
13690    #[test]
13691    fn tag_and_command_conditions_are_non_correlated() {
13692        // The tag/command map lookups must be emitted as non-correlated
13693        // `rowid IN (SELECT ...)` subqueries. A correlated `EXISTS (... WHERE
13694        // map.owner = packages.rowid ...)` re-scans the un-indexed map tables
13695        // per package row, which is quadratic (~19s on the real winget index
13696        // under SQLite, and an effective hang under Turso). Guard against a
13697        // regression back to the correlated form.
13698        let query = PackageQuery {
13699            query: Some("terminal".to_owned()),
13700            ..PackageQuery::default()
13701        };
13702
13703        let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13704
13705        assert!(where_clause.contains("packages.rowid IN (SELECT"));
13706        assert!(!where_clause.contains("EXISTS"));
13707        assert!(!where_clause.contains("= packages.rowid"));
13708    }
13709
13710    #[test]
13711    fn show_query_single_omits_tag_and_command_conditions() {
13712        let query = PackageQuery {
13713            query: Some("terminal".to_owned()),
13714            ..PackageQuery::default()
13715        };
13716
13717        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
13718
13719        assert!(!where_clause.contains("tags2"));
13720        assert!(!where_clause.contains("commands2"));
13721        assert_eq!(params.len(), 3);
13722    }
13723
13724    #[test]
13725    fn explicit_tag_filter_uses_exact_match() {
13726        let query = PackageQuery {
13727            tag: Some("terminal".to_owned()),
13728            ..PackageQuery::default()
13729        };
13730
13731        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13732
13733        assert!(where_clause.contains("tags2"));
13734        assert_eq!(params, vec!["terminal".to_owned()]);
13735    }
13736
13737    #[test]
13738    fn rest_tag_filter_uses_exact_match() {
13739        let query = PackageQuery {
13740            tag: Some("terminal".to_owned()),
13741            ..PackageQuery::default()
13742        };
13743        let info = RestInformation {
13744            required_package_match_fields: Vec::new(),
13745            unsupported_package_match_fields: Vec::new(),
13746            required_query_parameters: Vec::new(),
13747            ..RestInformation::default()
13748        };
13749
13750        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
13751        let filters = body["Filters"].as_array().expect("filters");
13752
13753        assert_eq!(filters.len(), 1);
13754        assert_eq!(filters[0]["PackageMatchField"], "Tag");
13755        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
13756        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
13757    }
13758
13759    #[test]
13760    fn selects_installer_using_requested_filters() {
13761        let installers = vec![
13762            Installer {
13763                architecture: Some("x86".to_owned()),
13764                installer_type: Some("zip".to_owned()),
13765                url: None,
13766                sha256: None,
13767                product_code: None,
13768                locale: Some("en-US".to_owned()),
13769                scope: Some("user".to_owned()),
13770                release_date: None,
13771                package_family_name: None,
13772                upgrade_code: None,
13773                platforms: Vec::new(),
13774                minimum_os_version: None,
13775                switches: InstallerSwitches::default(),
13776                commands: Vec::new(),
13777                package_dependencies: Vec::new(),
13778                require_explicit_upgrade: false,
13779                nested_installer_type: None,
13780                nested_installer_files: Vec::new(),
13781            },
13782            Installer {
13783                architecture: Some("x64".to_owned()),
13784                installer_type: Some("msix".to_owned()),
13785                url: None,
13786                sha256: None,
13787                product_code: None,
13788                locale: Some("en-US".to_owned()),
13789                scope: Some("user".to_owned()),
13790                release_date: None,
13791                package_family_name: None,
13792                upgrade_code: None,
13793                platforms: Vec::new(),
13794                minimum_os_version: None,
13795                switches: InstallerSwitches::default(),
13796                commands: vec!["demo".to_owned()],
13797                package_dependencies: Vec::new(),
13798                require_explicit_upgrade: false,
13799                nested_installer_type: None,
13800                nested_installer_files: Vec::new(),
13801            },
13802        ];
13803        let query = PackageQuery {
13804            installer_type: Some("msix".to_owned()),
13805            installer_architecture: Some("x64".to_owned()),
13806            install_scope: Some("user".to_owned()),
13807            locale: Some("en-US".to_owned()),
13808            ..PackageQuery::default()
13809        };
13810
13811        let selected = select_installer(&installers, &query).expect("selected installer");
13812        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
13813        assert_eq!(selected.architecture.as_deref(), Some("x64"));
13814    }
13815
13816    #[test]
13817    fn selects_first_installer_when_rank_ties() {
13818        let installers = vec![
13819            Installer {
13820                architecture: Some("x64".to_owned()),
13821                installer_type: Some("exe".to_owned()),
13822                url: None,
13823                sha256: None,
13824                product_code: None,
13825                locale: Some("en-US".to_owned()),
13826                scope: Some("machine".to_owned()),
13827                release_date: None,
13828                package_family_name: None,
13829                upgrade_code: None,
13830                platforms: Vec::new(),
13831                minimum_os_version: None,
13832                switches: InstallerSwitches::default(),
13833                commands: Vec::new(),
13834                package_dependencies: Vec::new(),
13835                require_explicit_upgrade: false,
13836                nested_installer_type: None,
13837                nested_installer_files: Vec::new(),
13838            },
13839            Installer {
13840                architecture: Some("x64".to_owned()),
13841                installer_type: Some("exe".to_owned()),
13842                url: None,
13843                sha256: None,
13844                product_code: None,
13845                locale: Some("en-US".to_owned()),
13846                scope: Some("user".to_owned()),
13847                release_date: None,
13848                package_family_name: None,
13849                upgrade_code: None,
13850                platforms: Vec::new(),
13851                minimum_os_version: None,
13852                switches: InstallerSwitches::default(),
13853                commands: Vec::new(),
13854                package_dependencies: Vec::new(),
13855                require_explicit_upgrade: false,
13856                nested_installer_type: None,
13857                nested_installer_files: Vec::new(),
13858            },
13859        ];
13860
13861        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
13862        assert_eq!(selected.scope.as_deref(), Some("machine"));
13863    }
13864
13865    #[test]
13866    fn selects_installer_using_requested_platform() {
13867        let installers = vec![
13868            Installer {
13869                architecture: Some("x64".to_owned()),
13870                installer_type: Some("exe".to_owned()),
13871                url: None,
13872                sha256: None,
13873                product_code: None,
13874                locale: Some("en-US".to_owned()),
13875                scope: Some("user".to_owned()),
13876                release_date: None,
13877                package_family_name: None,
13878                upgrade_code: None,
13879                platforms: vec!["Windows.Universal".to_owned()],
13880                minimum_os_version: None,
13881                switches: InstallerSwitches::default(),
13882                commands: Vec::new(),
13883                package_dependencies: Vec::new(),
13884                require_explicit_upgrade: false,
13885                nested_installer_type: None,
13886                nested_installer_files: Vec::new(),
13887            },
13888            Installer {
13889                architecture: Some("x64".to_owned()),
13890                installer_type: Some("exe".to_owned()),
13891                url: None,
13892                sha256: None,
13893                product_code: None,
13894                locale: Some("en-US".to_owned()),
13895                scope: Some("user".to_owned()),
13896                release_date: None,
13897                package_family_name: None,
13898                upgrade_code: None,
13899                platforms: vec!["Windows.Desktop".to_owned()],
13900                minimum_os_version: None,
13901                switches: InstallerSwitches::default(),
13902                commands: Vec::new(),
13903                package_dependencies: Vec::new(),
13904                require_explicit_upgrade: false,
13905                nested_installer_type: None,
13906                nested_installer_files: Vec::new(),
13907            },
13908        ];
13909        let query = PackageQuery {
13910            installer_type: Some("exe".to_owned()),
13911            platform: Some("Windows.Desktop".to_owned()),
13912            ..PackageQuery::default()
13913        };
13914
13915        let selected = select_installer(&installers, &query).expect("selected installer");
13916        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
13917    }
13918
13919    #[test]
13920    fn selects_installer_using_requested_os_version() {
13921        let installers = vec![
13922            Installer {
13923                architecture: Some("x64".to_owned()),
13924                installer_type: Some("exe".to_owned()),
13925                url: None,
13926                sha256: None,
13927                product_code: None,
13928                locale: Some("en-US".to_owned()),
13929                scope: Some("user".to_owned()),
13930                release_date: None,
13931                package_family_name: None,
13932                upgrade_code: None,
13933                platforms: Vec::new(),
13934                minimum_os_version: Some("10.0.22621.0".to_owned()),
13935                switches: InstallerSwitches::default(),
13936                commands: Vec::new(),
13937                package_dependencies: Vec::new(),
13938                require_explicit_upgrade: false,
13939                nested_installer_type: None,
13940                nested_installer_files: Vec::new(),
13941            },
13942            Installer {
13943                architecture: Some("x64".to_owned()),
13944                installer_type: Some("exe".to_owned()),
13945                url: None,
13946                sha256: None,
13947                product_code: None,
13948                locale: Some("en-US".to_owned()),
13949                scope: Some("user".to_owned()),
13950                release_date: None,
13951                package_family_name: None,
13952                upgrade_code: None,
13953                platforms: Vec::new(),
13954                minimum_os_version: Some("10.0.19041.0".to_owned()),
13955                switches: InstallerSwitches::default(),
13956                commands: Vec::new(),
13957                package_dependencies: Vec::new(),
13958                require_explicit_upgrade: false,
13959                nested_installer_type: None,
13960                nested_installer_files: Vec::new(),
13961            },
13962        ];
13963        let query = PackageQuery {
13964            installer_type: Some("exe".to_owned()),
13965            os_version: Some("10.0.19045.0".to_owned()),
13966            ..PackageQuery::default()
13967        };
13968
13969        let selected = select_installer(&installers, &query).expect("selected installer");
13970        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
13971    }
13972
13973    #[test]
13974    fn derives_correlation_name_candidates() {
13975        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
13976        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
13977        assert!(candidates.contains(&"PowerToys".to_owned()));
13978    }
13979
13980    #[test]
13981    fn correlates_installed_package_to_available_match() {
13982        let installed = InstalledPackage {
13983            name: "PowerToys (Preview) x64".to_owned(),
13984            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13985            installed_version: "0.98.1".to_owned(),
13986            publisher: None,
13987            scope: Some("Machine".to_owned()),
13988            installer_category: Some("exe".to_owned()),
13989            install_location: None,
13990            package_family_names: Vec::new(),
13991            product_codes: Vec::new(),
13992            upgrade_codes: Vec::new(),
13993            correlated: None,
13994            winget_package_identifier: None,
13995            winget_source_identifier: None,
13996            installed_version_canonical: false,
13997            correlated_requires_explicit_upgrade: false,
13998            correlated_lacks_compatible_installer: false,
13999        };
14000        let candidates = vec![SearchMatch {
14001            source_name: "winget".to_owned(),
14002            source_kind: SourceKind::PreIndexed,
14003            id: "Microsoft.PowerToys".to_owned(),
14004            name: "PowerToys".to_owned(),
14005            moniker: None,
14006            version: Some("0.98.1".to_owned()),
14007            channel: None,
14008            match_criteria: None,
14009        }];
14010
14011        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
14012        assert_eq!(correlated.id, "Microsoft.PowerToys");
14013    }
14014
14015    #[test]
14016    fn list_query_uses_available_lookup_for_tag_filters() {
14017        let query = ListQuery {
14018            tag: Some("terminal".to_owned()),
14019            ..ListQuery::default()
14020        };
14021
14022        assert!(list_query_needs_available_lookup(&query));
14023    }
14024
14025    #[test]
14026    fn list_tag_filter_requires_correlated_match() {
14027        let package = InstalledPackage {
14028            name: "PowerToys (Preview) x64".to_owned(),
14029            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
14030            installed_version: "0.98.1".to_owned(),
14031            publisher: None,
14032            scope: Some("Machine".to_owned()),
14033            installer_category: Some("exe".to_owned()),
14034            install_location: None,
14035            package_family_names: Vec::new(),
14036            product_codes: Vec::new(),
14037            upgrade_codes: Vec::new(),
14038            correlated: None,
14039            winget_package_identifier: None,
14040            winget_source_identifier: None,
14041            installed_version_canonical: false,
14042            correlated_requires_explicit_upgrade: false,
14043            correlated_lacks_compatible_installer: false,
14044        };
14045        let query = ListQuery {
14046            tag: Some("powertoys".to_owned()),
14047            ..ListQuery::default()
14048        };
14049
14050        assert!(!list_package_matches(&package, &query));
14051    }
14052
14053    #[test]
14054    fn list_lookup_ignores_display_count() {
14055        let query = ListQuery {
14056            query: Some("git".to_owned()),
14057            count: Some(5),
14058            ..ListQuery::default()
14059        };
14060
14061        let package_query = package_query_from_list_query(&query);
14062        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
14063    }
14064
14065    #[test]
14066    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
14067        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
14068        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
14069        // whichever candidate happened to come last in iteration order. The installed
14070        // display name's prefix decides the correct one.
14071        let installed = InstalledPackage {
14072            name: "Notepad++ (ARM 64-bit)".to_owned(),
14073            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
14074            installed_version: "8.8.9".to_owned(),
14075            publisher: None,
14076            scope: Some("Machine".to_owned()),
14077            installer_category: Some("exe".to_owned()),
14078            install_location: None,
14079            package_family_names: Vec::new(),
14080            product_codes: Vec::new(),
14081            upgrade_codes: Vec::new(),
14082            correlated: None,
14083            winget_package_identifier: None,
14084            winget_source_identifier: None,
14085            installed_version_canonical: false,
14086            correlated_requires_explicit_upgrade: false,
14087            correlated_lacks_compatible_installer: false,
14088        };
14089        let candidates = vec![
14090            SearchMatch {
14091                source_name: "winget".to_owned(),
14092                source_kind: SourceKind::PreIndexed,
14093                id: "ndd.Notepad--".to_owned(),
14094                name: "Notepad--".to_owned(),
14095                moniker: None,
14096                version: Some("1.0.0".to_owned()),
14097                channel: None,
14098                match_criteria: None,
14099            },
14100            SearchMatch {
14101                source_name: "winget".to_owned(),
14102                source_kind: SourceKind::PreIndexed,
14103                id: "Notepad++.Notepad++".to_owned(),
14104                name: "Notepad++".to_owned(),
14105                moniker: None,
14106                version: Some("8.9.5".to_owned()),
14107                channel: None,
14108                match_criteria: None,
14109            },
14110        ];
14111
14112        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
14113        assert_eq!(correlated.id, "Notepad++.Notepad++");
14114    }
14115
14116    #[test]
14117    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
14118        // Without the prefix bonus, the loose substring match picked up `Studio`
14119        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
14120        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
14121        // sort order favored the alphabetically-later candidate.
14122        let installed = InstalledPackage {
14123            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
14124            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
14125            installed_version: "40.10.18029".to_owned(),
14126            publisher: None,
14127            scope: Some("Machine".to_owned()),
14128            installer_category: Some("msi".to_owned()),
14129            install_location: None,
14130            package_family_names: Vec::new(),
14131            product_codes: Vec::new(),
14132            upgrade_codes: Vec::new(),
14133            correlated: None,
14134            winget_package_identifier: None,
14135            winget_source_identifier: None,
14136            installed_version_canonical: false,
14137            correlated_requires_explicit_upgrade: false,
14138            correlated_lacks_compatible_installer: false,
14139        };
14140        let candidates = vec![
14141            SearchMatch {
14142                source_name: "winget".to_owned(),
14143                source_kind: SourceKind::PreIndexed,
14144                id: "ZeroBrane.Studio".to_owned(),
14145                name: "Studio".to_owned(),
14146                moniker: None,
14147                version: Some("1.0".to_owned()),
14148                channel: None,
14149                match_criteria: None,
14150            },
14151            SearchMatch {
14152                source_name: "winget".to_owned(),
14153                source_kind: SourceKind::PreIndexed,
14154                id: "BrickLink.Studio".to_owned(),
14155                name: "Studio".to_owned(),
14156                moniker: None,
14157                version: Some("1.0".to_owned()),
14158                channel: None,
14159                match_criteria: None,
14160            },
14161            SearchMatch {
14162                source_name: "winget".to_owned(),
14163                source_kind: SourceKind::PreIndexed,
14164                id: "Microsoft.DotNet.SDK.10".to_owned(),
14165                name: "Microsoft .NET SDK 10.0".to_owned(),
14166                moniker: None,
14167                version: Some("10.0.204".to_owned()),
14168                channel: None,
14169                match_criteria: None,
14170            },
14171        ];
14172
14173        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
14174        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
14175    }
14176
14177    #[test]
14178    fn msix_packages_do_not_correlate_via_name() {
14179        // MSIX correlation must go through the v2 index's `pfns2` table — name
14180        // fallback is wrong because two MSIX packages can legitimately share a
14181        // display name without sharing identity (`Microsoft Edge Stable` MSIX
14182        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
14183        // the catalog Inno installer). Even when the names match exactly the
14184        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
14185        // PFN hit; that lookup happens in `correlate_installed_via_index`.
14186        let installed = InstalledPackage {
14187            name: "Microsoft Teams".to_owned(),
14188            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
14189            installed_version: "25290.205.4069.4894".to_owned(),
14190            publisher: None,
14191            scope: Some("User".to_owned()),
14192            installer_category: Some("msix".to_owned()),
14193            install_location: None,
14194            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
14195            product_codes: Vec::new(),
14196            upgrade_codes: Vec::new(),
14197            correlated: None,
14198            winget_package_identifier: None,
14199            winget_source_identifier: None,
14200            installed_version_canonical: false,
14201            correlated_requires_explicit_upgrade: false,
14202            correlated_lacks_compatible_installer: false,
14203        };
14204        let candidates = vec![SearchMatch {
14205            source_name: "winget".to_owned(),
14206            source_kind: SourceKind::PreIndexed,
14207            id: "Microsoft.Teams".to_owned(),
14208            name: "Microsoft Teams".to_owned(),
14209            moniker: None,
14210            version: Some("26106.1906.4665.7308".to_owned()),
14211            channel: None,
14212            match_criteria: None,
14213        }];
14214
14215        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
14216    }
14217
14218    #[test]
14219    fn name_fallback_refuses_ambiguous_winners() {
14220        // The user has Git installed (publisher "The Git Development Community"
14221        // — but publisher isn't in the scoring function). Two catalog packages
14222        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
14223        // publisher disambiguation they score identically; winget refuses to
14224        // correlate (the install lists with empty Source). pinget must do the
14225        // same to avoid manufacturing an upgrade against the wrong catalog
14226        // package.
14227        let installed = InstalledPackage {
14228            name: "Git".to_owned(),
14229            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
14230            installed_version: "2.53.0".to_owned(),
14231            publisher: Some("The Git Development Community".to_owned()),
14232            scope: Some("Machine".to_owned()),
14233            installer_category: Some("exe".to_owned()),
14234            install_location: None,
14235            package_family_names: Vec::new(),
14236            product_codes: Vec::new(),
14237            upgrade_codes: Vec::new(),
14238            correlated: None,
14239            winget_package_identifier: None,
14240            winget_source_identifier: None,
14241            installed_version_canonical: false,
14242            correlated_requires_explicit_upgrade: false,
14243            correlated_lacks_compatible_installer: false,
14244        };
14245        let candidates = vec![
14246            SearchMatch {
14247                source_name: "winget".to_owned(),
14248                source_kind: SourceKind::PreIndexed,
14249                id: "Git.Git".to_owned(),
14250                name: "Git".to_owned(),
14251                moniker: None,
14252                version: Some("2.54.0".to_owned()),
14253                channel: None,
14254                match_criteria: None,
14255            },
14256            SearchMatch {
14257                source_name: "winget".to_owned(),
14258                source_kind: SourceKind::PreIndexed,
14259                id: "Microsoft.Git".to_owned(),
14260                name: "Git".to_owned(),
14261                moniker: None,
14262                version: Some("2.53.0.0.7".to_owned()),
14263                channel: None,
14264                match_criteria: None,
14265            },
14266        ];
14267
14268        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
14269    }
14270
14271    #[test]
14272    fn msix_with_resource_string_name_does_not_correlate() {
14273        // Some MSIX entries have unresolved resource-string display names
14274        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
14275        // unrelated catalog package via the loose substring rule.
14276        let installed = InstalledPackage {
14277            name: "ms-resource:appDisplayName".to_owned(),
14278            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
14279            installed_version: "1.28.239.0".to_owned(),
14280            publisher: None,
14281            scope: Some("User".to_owned()),
14282            installer_category: Some("msix".to_owned()),
14283            install_location: None,
14284            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
14285            product_codes: Vec::new(),
14286            upgrade_codes: Vec::new(),
14287            correlated: None,
14288            winget_package_identifier: None,
14289            winget_source_identifier: None,
14290            installed_version_canonical: false,
14291            correlated_requires_explicit_upgrade: false,
14292            correlated_lacks_compatible_installer: false,
14293        };
14294        let candidates = vec![SearchMatch {
14295            source_name: "winget".to_owned(),
14296            source_kind: SourceKind::PreIndexed,
14297            id: "Microsoft.AppInstaller".to_owned(),
14298            name: "App Installer".to_owned(),
14299            moniker: None,
14300            version: Some("1.28.240.0".to_owned()),
14301            channel: None,
14302            match_criteria: None,
14303        }];
14304
14305        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
14306    }
14307
14308    #[test]
14309    fn strict_list_correlation_avoids_short_substring_matches() {
14310        let installed = InstalledPackage {
14311            name: "GitHub".to_owned(),
14312            local_id: r"ARP\User\X64\GitHub".to_owned(),
14313            installed_version: "1.0.0".to_owned(),
14314            publisher: None,
14315            scope: Some("User".to_owned()),
14316            installer_category: Some("exe".to_owned()),
14317            install_location: None,
14318            package_family_names: Vec::new(),
14319            product_codes: Vec::new(),
14320            upgrade_codes: Vec::new(),
14321            correlated: None,
14322            winget_package_identifier: None,
14323            winget_source_identifier: None,
14324            installed_version_canonical: false,
14325            correlated_requires_explicit_upgrade: false,
14326            correlated_lacks_compatible_installer: false,
14327        };
14328        let candidates = vec![SearchMatch {
14329            source_name: "winget".to_owned(),
14330            source_kind: SourceKind::PreIndexed,
14331            id: "Git.Git.PreRelease".to_owned(),
14332            name: "Git".to_owned(),
14333            moniker: None,
14334            version: Some("2.54.0".to_owned()),
14335            channel: None,
14336            match_criteria: None,
14337        }];
14338
14339        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
14340    }
14341
14342    #[test]
14343    fn detects_available_upgrade_from_correlated_package() {
14344        let package = InstalledPackage {
14345            name: "AzCopy v10".to_owned(),
14346            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
14347            installed_version: "10.32.2".to_owned(),
14348            publisher: None,
14349            scope: Some("Machine".to_owned()),
14350            installer_category: Some("exe".to_owned()),
14351            install_location: None,
14352            package_family_names: Vec::new(),
14353            product_codes: Vec::new(),
14354            upgrade_codes: Vec::new(),
14355            correlated: Some(SearchMatch {
14356                source_name: "winget".to_owned(),
14357                source_kind: SourceKind::PreIndexed,
14358                id: "Microsoft.Azure.AZCopy.10".to_owned(),
14359                name: "AzCopy v10".to_owned(),
14360                moniker: None,
14361                version: Some("10.32.3".to_owned()),
14362                channel: None,
14363                match_criteria: None,
14364            }),
14365            winget_package_identifier: None,
14366            winget_source_identifier: None,
14367            installed_version_canonical: false,
14368            correlated_requires_explicit_upgrade: false,
14369            correlated_lacks_compatible_installer: false,
14370        };
14371
14372        assert!(installed_package_has_upgrade(&package));
14373    }
14374
14375    #[test]
14376    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
14377        let package = InstalledPackage {
14378            name: "Example Tool".to_owned(),
14379            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
14380            installed_version: "Unknown".to_owned(),
14381            publisher: None,
14382            scope: Some("User".to_owned()),
14383            installer_category: Some("exe".to_owned()),
14384            install_location: None,
14385            package_family_names: Vec::new(),
14386            product_codes: Vec::new(),
14387            upgrade_codes: Vec::new(),
14388            correlated: Some(SearchMatch {
14389                source_name: "winget".to_owned(),
14390                source_kind: SourceKind::PreIndexed,
14391                id: "Contoso.ExampleTool".to_owned(),
14392                name: "Example Tool".to_owned(),
14393                moniker: None,
14394                version: Some("2.0.0".to_owned()),
14395                channel: None,
14396                match_criteria: None,
14397            }),
14398            winget_package_identifier: None,
14399            winget_source_identifier: None,
14400            installed_version_canonical: false,
14401            correlated_requires_explicit_upgrade: false,
14402            correlated_lacks_compatible_installer: false,
14403        };
14404        let query = ListQuery {
14405            upgrade_only: true,
14406            include_unknown: true,
14407            ..Default::default()
14408        };
14409
14410        assert!(installed_package_matches_upgrade_filter(&package, &query));
14411        assert_eq!(
14412            list_match_from_installed(package, &[]).available_version.as_deref(),
14413            Some("2.0.0")
14414        );
14415    }
14416
14417    #[test]
14418    fn create_repair_list_query_includes_installed_selectors() {
14419        let request = RepairRequest {
14420            query: PackageQuery {
14421                query: Some("powertoys".to_owned()),
14422                id: Some("Microsoft.PowerToys".to_owned()),
14423                name: Some("PowerToys".to_owned()),
14424                moniker: Some("powertoys".to_owned()),
14425                source: Some("winget".to_owned()),
14426                exact: true,
14427                version: Some("0.98.1".to_owned()),
14428                install_scope: Some("Machine".to_owned()),
14429                ..PackageQuery::default()
14430            },
14431            manifest_path: None,
14432            product_code: Some("{GUID}".to_owned()),
14433            mode: InstallerMode::Silent,
14434            log_path: Some(PathBuf::from("repair.log")),
14435            accept_package_agreements: true,
14436            force: true,
14437            ignore_security_hash: true,
14438        };
14439
14440        let query = Repository::create_repair_list_query(&request);
14441        assert_eq!(query.query.as_deref(), Some("powertoys"));
14442        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
14443        assert_eq!(query.name.as_deref(), Some("PowerToys"));
14444        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
14445        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
14446        assert_eq!(query.version.as_deref(), Some("0.98.1"));
14447        assert_eq!(query.source.as_deref(), Some("winget"));
14448        assert!(query.exact);
14449        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
14450        assert_eq!(query.count, Some(100));
14451    }
14452
14453    #[test]
14454    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
14455        let request = RepairRequest {
14456            query: PackageQuery {
14457                name: Some("PowerToys".to_owned()),
14458                source: Some("winget".to_owned()),
14459                locale: Some("en-US".to_owned()),
14460                installer_architecture: Some("x64".to_owned()),
14461                install_scope: Some("Machine".to_owned()),
14462                ..PackageQuery::default()
14463            },
14464            manifest_path: None,
14465            product_code: None,
14466            mode: InstallerMode::SilentWithProgress,
14467            log_path: Some(PathBuf::from("repair.log")),
14468            accept_package_agreements: true,
14469            force: false,
14470            ignore_security_hash: true,
14471        };
14472        let installed = ListMatch {
14473            name: "PowerToys".to_owned(),
14474            id: "Microsoft.PowerToys".to_owned(),
14475            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
14476            installed_version: "0.98.1".to_owned(),
14477            available_version: Some("0.98.2".to_owned()),
14478            source_name: Some("winget".to_owned()),
14479            publisher: None,
14480            scope: Some("Machine".to_owned()),
14481            installer_category: Some("exe".to_owned()),
14482            install_location: None,
14483            package_family_names: Vec::new(),
14484            product_codes: Vec::new(),
14485            upgrade_codes: Vec::new(),
14486        };
14487
14488        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
14489        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
14490        assert!(install_request.query.query.is_none());
14491        assert!(install_request.query.name.is_none());
14492        assert!(install_request.query.moniker.is_none());
14493        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
14494        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
14495        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
14496        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
14497        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
14498        assert!(install_request.query.exact);
14499        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
14500        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
14501        assert!(install_request.accept_package_agreements);
14502        assert!(install_request.force);
14503        assert!(install_request.ignore_security_hash);
14504    }
14505
14506    #[test]
14507    fn source_scoped_pin_operations_round_trip() {
14508        let app_root = temp_app_root("pins");
14509        let result = (|| -> Result<()> {
14510            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14511            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
14512            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
14513            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
14514
14515            assert_eq!(repository.list_pins(None)?.len(), 3);
14516            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
14517            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
14518
14519            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
14520            assert_eq!(repository.list_pins(None)?.len(), 2);
14521            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
14522
14523            repository.reset_pins(Some("test"))?;
14524            let remaining = repository.list_pins(None)?;
14525            assert_eq!(remaining.len(), 1);
14526            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
14527
14528            repository.reset_pins(None)?;
14529            assert!(repository.list_pins(None)?.is_empty());
14530            Ok(())
14531        })();
14532
14533        let _ = fs::remove_dir_all(&app_root);
14534        result.expect("pin round trip");
14535    }
14536
14537    #[test]
14538    fn pin_operations_work_with_packaged_pin_schema() {
14539        let app_root = temp_app_root("pins_packaged_schema");
14540        let result = (|| -> Result<()> {
14541            let db_path = pins_db_path(&app_root);
14542            if let Some(parent) = db_path.parent() {
14543                fs::create_dir_all(parent)?;
14544            }
14545
14546            let conn = Repository::open_sqlite_connection(db_path.clone())?;
14547            execute_batch_sql(
14548                &conn,
14549                "CREATE TABLE pin (
14550                    package_id TEXT NOT NULL,
14551                    source_id TEXT NOT NULL,
14552                    type INTEGER NOT NULL,
14553                    version TEXT NOT NULL,
14554                    PRIMARY KEY (package_id, source_id)
14555                )",
14556            )?;
14557            conn.cacheflush().context("failed to flush pin database")?;
14558            drop(conn);
14559
14560            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14561            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
14562
14563            let pins = repository.list_pins(None)?;
14564            assert_eq!(pins.len(), 1);
14565            assert_eq!(pins[0].package_id, "Contoso.Tool");
14566            assert_eq!(pins[0].version, "1.2.*");
14567            assert_eq!(pins[0].source_id, "winget");
14568            assert_eq!(pins[0].pin_type, PinType::Gating);
14569
14570            let conn = Repository::open_sqlite_connection(db_path)?;
14571            let stored_pin = query_optional_value(
14572                &conn,
14573                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
14574                vec![
14575                    SqlValue::Text("Contoso.Tool".to_owned()),
14576                    SqlValue::Text("winget".to_owned()),
14577                ],
14578                |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
14579            )?
14580            .ok_or_else(|| anyhow!("expected stored pin"))?;
14581            assert_eq!(stored_pin.0, 3);
14582            assert_eq!(stored_pin.1, "1.2.*");
14583
14584            Ok(())
14585        })();
14586
14587        let _ = fs::remove_dir_all(&app_root);
14588        result.expect("packaged pin schema round trip");
14589    }
14590
14591    #[test]
14592    fn version_pin_patterns_match_exact_and_prefix_values() {
14593        assert!(version_matches_pin_pattern("1.2.3", "*"));
14594        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
14595        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
14596        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
14597        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
14598    }
14599
14600    #[test]
14601    fn source_specific_pins_override_source_agnostic_pins() {
14602        let item = ListMatch {
14603            name: "Contoso Tool".to_owned(),
14604            id: "Contoso.Tool".to_owned(),
14605            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14606            installed_version: "1.0.0".to_owned(),
14607            available_version: Some("2.0.0".to_owned()),
14608            source_name: Some("winget".to_owned()),
14609            publisher: None,
14610            scope: None,
14611            installer_category: None,
14612            install_location: None,
14613            package_family_names: Vec::new(),
14614            product_codes: Vec::new(),
14615            upgrade_codes: Vec::new(),
14616        };
14617        let pins = vec![
14618            PinRecord {
14619                package_id: "Contoso.Tool".to_owned(),
14620                version: "*".to_owned(),
14621                source_id: String::new(),
14622                pin_type: PinType::Blocking,
14623            },
14624            PinRecord {
14625                package_id: "Contoso.Tool".to_owned(),
14626                version: "2.0.*".to_owned(),
14627                source_id: "winget".to_owned(),
14628                pin_type: PinType::Pinning,
14629            },
14630        ];
14631
14632        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
14633    }
14634
14635    #[test]
14636    fn blocking_pin_blocks_upgrade() {
14637        let item = ListMatch {
14638            name: "Contoso Tool".to_owned(),
14639            id: "Contoso.Tool".to_owned(),
14640            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14641            installed_version: "1.0.0".to_owned(),
14642            available_version: Some("2.0.0".to_owned()),
14643            source_name: Some("winget".to_owned()),
14644            publisher: None,
14645            scope: None,
14646            installer_category: None,
14647            install_location: None,
14648            package_family_names: Vec::new(),
14649            product_codes: Vec::new(),
14650            upgrade_codes: Vec::new(),
14651        };
14652        let pins = vec![PinRecord {
14653            package_id: "Contoso.Tool".to_owned(),
14654            version: "*".to_owned(),
14655            source_id: "winget".to_owned(),
14656            pin_type: PinType::Blocking,
14657        }];
14658
14659        assert!(is_upgrade_blocked_by_pin(&item, &pins));
14660    }
14661
14662    #[cfg(windows)]
14663    #[test]
14664    fn parses_msix_package_full_name_into_version_and_family() {
14665        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
14666            .expect("package metadata");
14667
14668        assert_eq!(parsed.version, "0.98.1.0");
14669        assert_eq!(parsed.resource_id, "");
14670        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
14671        assert!(!is_msix_split_resource_package(&parsed.resource_id));
14672    }
14673
14674    #[cfg(windows)]
14675    #[test]
14676    fn classifies_msix_split_resource_package() {
14677        let parsed = parse_msix_package_full_name(
14678            "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe",
14679        )
14680        .expect("package metadata");
14681
14682        assert_eq!(parsed.version, "1.97.17002.0");
14683        assert_eq!(parsed.resource_id, "split.scale-125");
14684        assert_eq!(
14685            parsed.family_name,
14686            "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe"
14687        );
14688        assert!(is_msix_split_resource_package(&parsed.resource_id));
14689    }
14690
14691    #[cfg(windows)]
14692    #[test]
14693    fn recognizes_windows_system_paths() {
14694        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
14695        assert!(!is_windows_system_path(
14696            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
14697        ));
14698    }
14699
14700    #[test]
14701    fn search_ranking_prefers_exact_name_over_tag_match() {
14702        let query = PackageQuery {
14703            query: Some("PowerToys".to_owned()),
14704            ..Default::default()
14705        };
14706        let exact_name = SearchMatch {
14707            source_name: "winget".to_owned(),
14708            source_kind: SourceKind::PreIndexed,
14709            id: "Microsoft.PowerToys".to_owned(),
14710            name: "PowerToys".to_owned(),
14711            moniker: Some("powertoys".to_owned()),
14712            version: Some("0.98.1".to_owned()),
14713            channel: None,
14714            match_criteria: Some("Moniker: powertoys".to_owned()),
14715        };
14716        let tag_match = SearchMatch {
14717            source_name: "winget".to_owned(),
14718            source_kind: SourceKind::PreIndexed,
14719            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
14720            name: "QR Codes for Command Palette".to_owned(),
14721            moniker: None,
14722            version: Some("0.4.0.0".to_owned()),
14723            channel: None,
14724            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
14725        };
14726
14727        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
14728    }
14729
14730    #[test]
14731    fn select_best_text_match_prefers_exact_tag_value() {
14732        let values = vec![
14733            "microsoft-powertoys".to_owned(),
14734            "powertoys-run".to_owned(),
14735            "powertoys".to_owned(),
14736        ];
14737
14738        assert_eq!(
14739            select_best_text_match(values, "PowerToys", false).as_deref(),
14740            Some("powertoys")
14741        );
14742    }
14743
14744    #[test]
14745    fn search_match_criteria_is_blank_for_name_match() {
14746        let query = PackageQuery {
14747            query: Some("PowerToys".to_owned()),
14748            ..Default::default()
14749        };
14750
14751        let result = infer_match_criteria(
14752            "Microsoft.PowerToys",
14753            "PowerToys",
14754            Some("powertoys"),
14755            &query,
14756            SearchSemantics::Many,
14757            |_| Ok(Some("powertoys".to_owned())),
14758            |_| Ok(None),
14759        )
14760        .expect("match criteria");
14761
14762        assert_eq!(result, None);
14763    }
14764
14765    #[test]
14766    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
14767        let query = PackageQuery {
14768            query: Some("PowerToys".to_owned()),
14769            ..Default::default()
14770        };
14771        let unknown_version = SearchMatch {
14772            source_name: "msstore".to_owned(),
14773            source_kind: SourceKind::Rest,
14774            id: "XP89DCGQ3K6VLD".to_owned(),
14775            name: "Microsoft PowerToys".to_owned(),
14776            moniker: None,
14777            version: Some("Unknown".to_owned()),
14778            channel: None,
14779            match_criteria: None,
14780        };
14781        let tag_match = SearchMatch {
14782            source_name: "winget".to_owned(),
14783            source_kind: SourceKind::PreIndexed,
14784            id: "Riri.QRCodeforCmdPal".to_owned(),
14785            name: "QR Code for CmdPal".to_owned(),
14786            moniker: None,
14787            version: Some("0.0.1.0".to_owned()),
14788            channel: None,
14789            match_criteria: Some("Tag: powertoys".to_owned()),
14790        };
14791
14792        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
14793    }
14794
14795    #[test]
14796    fn search_source_fetch_results_ignore_small_display_count() {
14797        let query = PackageQuery {
14798            count: Some(5),
14799            ..Default::default()
14800        };
14801
14802        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
14803        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
14804    }
14805
14806    #[test]
14807    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
14808        let query = PackageQuery {
14809            query: Some("PowerToys".to_owned()),
14810            ..Default::default()
14811        };
14812        let prefix_name = SearchMatch {
14813            source_name: "winget".to_owned(),
14814            source_kind: SourceKind::PreIndexed,
14815            id: "advaith.CurrencyConverterPowerToys".to_owned(),
14816            name: "PowerToys-Run-Currency-Converter".to_owned(),
14817            moniker: None,
14818            version: Some("1.5.4".to_owned()),
14819            channel: None,
14820            match_criteria: None,
14821        };
14822        let exact_tag = SearchMatch {
14823            source_name: "winget".to_owned(),
14824            source_kind: SourceKind::PreIndexed,
14825            id: "Riri.QRCodeforCmdPal".to_owned(),
14826            name: "QR Code for CmdPal".to_owned(),
14827            moniker: None,
14828            version: Some("0.0.1.0".to_owned()),
14829            channel: None,
14830            match_criteria: Some("Tag: powertoys".to_owned()),
14831        };
14832
14833        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
14834    }
14835
14836    #[test]
14837    fn list_sort_prefers_main_package_then_sparse_app() {
14838        let main = InstalledPackage {
14839            name: "PowerToys (Preview) x64".to_owned(),
14840            local_id: r"ARP\User\X64\PowerToys".to_owned(),
14841            installed_version: "0.98.1".to_owned(),
14842            publisher: None,
14843            scope: Some("User".to_owned()),
14844            installer_category: Some("exe".to_owned()),
14845            install_location: None,
14846            package_family_names: Vec::new(),
14847            product_codes: Vec::new(),
14848            upgrade_codes: Vec::new(),
14849            correlated: None,
14850            winget_package_identifier: None,
14851            winget_source_identifier: None,
14852            installed_version_canonical: false,
14853            correlated_requires_explicit_upgrade: false,
14854            correlated_lacks_compatible_installer: false,
14855        };
14856        let sparse = InstalledPackage {
14857            name: "PowerToys.SparseApp".to_owned(),
14858            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14859            installed_version: "0.98.1.0".to_owned(),
14860            publisher: None,
14861            scope: Some("User".to_owned()),
14862            installer_category: Some("msix".to_owned()),
14863            install_location: None,
14864            package_family_names: Vec::new(),
14865            product_codes: Vec::new(),
14866            upgrade_codes: Vec::new(),
14867            correlated: None,
14868            winget_package_identifier: None,
14869            winget_source_identifier: None,
14870            installed_version_canonical: false,
14871            correlated_requires_explicit_upgrade: false,
14872            correlated_lacks_compatible_installer: false,
14873        };
14874        let extension = InstalledPackage {
14875            name: "PowerToys FileLocksmith Context Menu".to_owned(),
14876            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14877            installed_version: "0.98.1.0".to_owned(),
14878            publisher: None,
14879            scope: Some("User".to_owned()),
14880            installer_category: Some("msix".to_owned()),
14881            install_location: None,
14882            package_family_names: Vec::new(),
14883            product_codes: Vec::new(),
14884            upgrade_codes: Vec::new(),
14885            correlated: None,
14886            winget_package_identifier: None,
14887            winget_source_identifier: None,
14888            installed_version_canonical: false,
14889            correlated_requires_explicit_upgrade: false,
14890            correlated_lacks_compatible_installer: false,
14891        };
14892
14893        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
14894        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
14895    }
14896
14897    #[test]
14898    #[cfg(windows)]
14899    fn arp_subkey_name_extracts_registry_subkey() {
14900        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
14901        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
14902    }
14903
14904    #[test]
14905    #[cfg(windows)]
14906    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
14907        let installed = ListMatch {
14908            name: "ShareX".to_owned(),
14909            id: "ShareX.ShareX".to_owned(),
14910            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
14911            installed_version: "19.0.2".to_owned(),
14912            available_version: None,
14913            source_name: Some("winget".to_owned()),
14914            publisher: Some("ShareX Team".to_owned()),
14915            scope: Some("Machine".to_owned()),
14916            installer_category: Some("exe".to_owned()),
14917            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
14918            package_family_names: Vec::new(),
14919            product_codes: Vec::new(),
14920            upgrade_codes: Vec::new(),
14921        };
14922
14923        assert!(registry_entry_matches_installed_package(
14924            "ShareX", "ShareX", None, &installed
14925        ));
14926        assert!(!registry_entry_matches_installed_package(
14927            "ShareX.ShareX",
14928            "ShareX.ShareX",
14929            None,
14930            &installed
14931        ));
14932    }
14933
14934    #[test]
14935    #[cfg(windows)]
14936    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
14937        assert_eq!(
14938            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
14939            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
14940        );
14941        assert_eq!(
14942            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
14943            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
14944        );
14945        assert_eq!(
14946            r#""C:\Program Files\ShareX\unins000.exe""#,
14947            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
14948        );
14949        assert_eq!(
14950            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14951            build_uninstall_command(
14952                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14953                true,
14954                false,
14955            )
14956        );
14957    }
14958
14959    #[test]
14960    #[cfg(windows)]
14961    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
14962        let installed = ListMatch {
14963            name: "lazygit".to_owned(),
14964            id: "JesseDuffield.lazygit".to_owned(),
14965            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
14966            installed_version: "0.61.1".to_owned(),
14967            available_version: None,
14968            source_name: Some("winget".to_owned()),
14969            publisher: Some("Jesse Duffield".to_owned()),
14970            scope: Some("User".to_owned()),
14971            installer_category: Some("exe".to_owned()),
14972            install_location: Some(
14973                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
14974            ),
14975            package_family_names: Vec::new(),
14976            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
14977            upgrade_codes: Vec::new(),
14978        };
14979        let mut request = UninstallRequest::new(PackageQuery {
14980            id: Some("JesseDuffield.lazygit".to_owned()),
14981            install_scope: Some("user".to_owned()),
14982            ..PackageQuery::default()
14983        });
14984        request.mode = InstallerMode::Silent;
14985
14986        assert_eq!(
14987            Some(vec![
14988                "uninstall".to_owned(),
14989                "--id".to_owned(),
14990                "JesseDuffield.lazygit".to_owned(),
14991                "--exact".to_owned(),
14992                "--disable-interactivity".to_owned(),
14993                "--source".to_owned(),
14994                "winget".to_owned(),
14995                "--scope".to_owned(),
14996                "user".to_owned(),
14997                "--silent".to_owned(),
14998            ]),
14999            build_winget_uninstall_arguments(&installed, &request)
15000        );
15001
15002        assert_eq!(
15003            Some(vec![
15004                "uninstall".to_owned(),
15005                "--id".to_owned(),
15006                "JesseDuffield.lazygit".to_owned(),
15007                "--exact".to_owned(),
15008                "--disable-interactivity".to_owned(),
15009                "--source".to_owned(),
15010                "winget".to_owned(),
15011                "--silent".to_owned(),
15012            ]),
15013            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
15014        );
15015    }
15016
15017    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
15018        PackageVersionDataEntry {
15019            version: version.to_owned(),
15020            manifest_relative_path: format!("manifests/x/{version}"),
15021            manifest_hash: "00".repeat(32),
15022            arp_min_version: arp_min.map(str::to_owned),
15023            arp_max_version: arp_max.map(str::to_owned),
15024        }
15025    }
15026
15027    fn v1_version(version: &str, channel: &str) -> V1VersionRow {
15028        V1VersionRow {
15029            version: version.to_owned(),
15030            channel: channel.to_owned(),
15031            pathpart_id: 0,
15032            manifest_hash: None,
15033        }
15034    }
15035
15036    #[derive(Clone)]
15037    struct TestHttpResponse {
15038        status: u16,
15039        body: Vec<u8>,
15040        source_version: Option<String>,
15041        etag: Option<String>,
15042        last_modified: Option<String>,
15043    }
15044
15045    struct TestHttpServer {
15046        addr: SocketAddr,
15047        routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
15048        counts: Arc<Mutex<HashMap<String, usize>>>,
15049        running: Arc<AtomicBool>,
15050        handle: Option<JoinHandle<()>>,
15051    }
15052
15053    impl TestHttpServer {
15054        fn start() -> Self {
15055            let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
15056            let addr = listener.local_addr().expect("test HTTP server address");
15057            let routes = Arc::new(Mutex::new(HashMap::new()));
15058            let counts = Arc::new(Mutex::new(HashMap::new()));
15059            let running = Arc::new(AtomicBool::new(true));
15060            let thread_routes = Arc::clone(&routes);
15061            let thread_counts = Arc::clone(&counts);
15062            let thread_running = Arc::clone(&running);
15063            let handle = thread::spawn(move || {
15064                while thread_running.load(AtomicOrdering::SeqCst) {
15065                    let Ok((stream, _)) = listener.accept() else {
15066                        continue;
15067                    };
15068                    handle_test_http_request(stream, &thread_routes, &thread_counts);
15069                }
15070            });
15071
15072            Self {
15073                addr,
15074                routes,
15075                counts,
15076                running,
15077                handle: Some(handle),
15078            }
15079        }
15080
15081        fn url(&self) -> String {
15082            format!("http://{}", self.addr)
15083        }
15084
15085        fn set_bytes(&self, path: &str, body: Vec<u8>) {
15086            self.set_response(
15087                path,
15088                TestHttpResponse {
15089                    status: 200,
15090                    body,
15091                    source_version: None,
15092                    etag: None,
15093                    last_modified: None,
15094                },
15095            );
15096        }
15097
15098        fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
15099            self.set_source_package_with_validators(body, source_version, None, None);
15100        }
15101
15102        fn set_source_package_with_validators(
15103            &self,
15104            body: Vec<u8>,
15105            source_version: &str,
15106            etag: Option<&str>,
15107            last_modified: Option<&str>,
15108        ) {
15109            self.set_response(
15110                "/source.msix",
15111                TestHttpResponse {
15112                    status: 200,
15113                    body,
15114                    source_version: Some(source_version.to_owned()),
15115                    etag: etag.map(str::to_owned),
15116                    last_modified: last_modified.map(str::to_owned),
15117                },
15118            );
15119        }
15120
15121        fn set_status(&self, path: &str, status: u16) {
15122            self.set_response(
15123                path,
15124                TestHttpResponse {
15125                    status,
15126                    body: Vec::new(),
15127                    source_version: None,
15128                    etag: None,
15129                    last_modified: None,
15130                },
15131            );
15132        }
15133
15134        fn set_response(&self, path: &str, response: TestHttpResponse) {
15135            self.routes
15136                .lock()
15137                .expect("test HTTP routes")
15138                .insert(path.to_owned(), response);
15139        }
15140
15141        fn request_count(&self, path: &str) -> usize {
15142            self.counts
15143                .lock()
15144                .expect("test HTTP counts")
15145                .get(path)
15146                .copied()
15147                .unwrap_or_default()
15148        }
15149    }
15150
15151    impl Drop for TestHttpServer {
15152        fn drop(&mut self) {
15153            self.running.store(false, AtomicOrdering::SeqCst);
15154            let _ = TcpStream::connect(self.addr);
15155            if let Some(handle) = self.handle.take() {
15156                let _ = handle.join();
15157            }
15158        }
15159    }
15160
15161    fn handle_test_http_request(
15162        mut stream: TcpStream,
15163        routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
15164        counts: &Arc<Mutex<HashMap<String, usize>>>,
15165    ) {
15166        let mut buffer = [0u8; 2048];
15167        let read = stream.read(&mut buffer).unwrap_or(0);
15168        let request = String::from_utf8_lossy(&buffer[..read]);
15169        let path = request
15170            .lines()
15171            .next()
15172            .and_then(|line| line.split_whitespace().nth(1))
15173            .unwrap_or("/");
15174        *counts
15175            .lock()
15176            .expect("test HTTP counts")
15177            .entry(path.to_owned())
15178            .or_default() += 1;
15179        let response = routes.lock().expect("test HTTP routes").get(path).cloned();
15180        let response = response.unwrap_or(TestHttpResponse {
15181            status: 404,
15182            body: Vec::new(),
15183            source_version: None,
15184            etag: None,
15185            last_modified: None,
15186        });
15187        let not_modified = response.status == 200
15188            && (response.etag.as_deref().is_some_and(|etag| {
15189                request_header_value(&request, "If-None-Match")
15190                    .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
15191            }) || response.last_modified.as_deref().is_some_and(|last_modified| {
15192                request_header_value(&request, "If-Modified-Since")
15193                    .is_some_and(|value| value.eq_ignore_ascii_case(last_modified))
15194            }));
15195        let status = if not_modified { 304 } else { response.status };
15196        let body = if not_modified {
15197            &[][..]
15198        } else {
15199            response.body.as_slice()
15200        };
15201        let reason = match status {
15202            200 => "OK",
15203            304 => "Not Modified",
15204            _ => "Not Found",
15205        };
15206        let mut headers = format!(
15207            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
15208            status,
15209            reason,
15210            body.len()
15211        );
15212        if let Some(source_version) = response.source_version {
15213            headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
15214        }
15215        if let Some(etag) = response.etag {
15216            headers.push_str(&format!("ETag: {etag}\r\n"));
15217        }
15218        if let Some(last_modified) = response.last_modified {
15219            headers.push_str(&format!("Last-Modified: {last_modified}\r\n"));
15220        }
15221        headers.push_str("\r\n");
15222        stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
15223        stream.write_all(body).expect("write test HTTP body");
15224    }
15225
15226    fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
15227        request.lines().find_map(|line| {
15228            let (header_name, value) = line.split_once(':')?;
15229            header_name.eq_ignore_ascii_case(name).then(|| value.trim())
15230        })
15231    }
15232
15233    fn test_source_record(source_url: String) -> SourceRecord {
15234        SourceRecord {
15235            name: "test-winget".to_owned(),
15236            kind: SourceKind::PreIndexed,
15237            arg: source_url,
15238            identifier: "Test.Source".to_owned(),
15239            trust_level: "Trusted".to_owned(),
15240            explicit: false,
15241            priority: 0,
15242            last_update: Some(Utc::now()),
15243            source_version: None,
15244            etag: None,
15245            last_modified: None,
15246        }
15247    }
15248
15249    fn open_test_preindexed_repository(
15250        app_root: &Path,
15251        source: &SourceRecord,
15252        interval: Option<Duration>,
15253    ) -> Repository {
15254        fs::create_dir_all(app_root).expect("create app root");
15255        save_store(
15256            app_root,
15257            &SourceStore {
15258                sources: vec![source.clone()],
15259            },
15260        )
15261        .expect("save source store");
15262        Repository::open_with_options(
15263            RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
15264        )
15265        .expect("open repository")
15266    }
15267
15268    fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
15269        let index_path = preindexed_index_path(app_root, source);
15270        if let Some(parent) = index_path.parent() {
15271            fs::create_dir_all(parent).expect("create source state directory");
15272        }
15273        fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
15274    }
15275
15276    #[test]
15277    fn replace_file_failure_keeps_existing_target() {
15278        let root = temp_app_root("replace_file_failure");
15279        fs::create_dir_all(&root).expect("create replace root");
15280        let target = root.join("index.db");
15281        let missing_source = root.join("missing.tmp");
15282        fs::write(&target, b"old-index").expect("write existing target");
15283
15284        let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
15285
15286        assert!(format!("{error:#}").contains("failed to replace"));
15287        assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
15288        let _ = fs::remove_dir_all(root);
15289    }
15290
15291    #[cfg(windows)]
15292    #[test]
15293    fn replace_file_succeeds_while_target_is_open() {
15294        use std::os::windows::fs::OpenOptionsExt;
15295        // FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE — the sharing
15296        // mode SQLite/Turso uses. An atomic MOVEFILE_REPLACE_EXISTING is denied
15297        // while this handle is open, so the stage-aside fallback must take over.
15298        const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
15299
15300        let root = temp_app_root("replace_file_open_target");
15301        fs::create_dir_all(&root).expect("create replace root");
15302        let target = root.join("index.db");
15303        let source = root.join("index.new.tmp");
15304        fs::write(&target, b"old-index").expect("write existing target");
15305        fs::write(&source, b"new-index").expect("write replacement");
15306
15307        let held = OpenOptions::new()
15308            .read(true)
15309            .share_mode(SHARE_ALL)
15310            .open(&target)
15311            .expect("open target with share-delete");
15312
15313        replace_file(&source, &target).expect("stage-aside replace should succeed");
15314
15315        assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
15316        drop(held);
15317        let _ = fs::remove_dir_all(root);
15318    }
15319
15320    fn make_source_package(versions: &[&str]) -> Vec<u8> {
15321        let cursor = Cursor::new(Vec::new());
15322        let mut archive = zip::ZipWriter::new(cursor);
15323        let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
15324        archive
15325            .start_file("Public/index.db", options)
15326            .expect("start index entry");
15327        archive
15328            .write_all(&make_v1_index_bytes(versions))
15329            .expect("write index entry");
15330        archive.finish().expect("finish source package").into_inner()
15331    }
15332
15333    fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
15334        let root = temp_app_root("preindexed_index_bytes");
15335        fs::create_dir_all(&root).expect("create index temp root");
15336        let db_path = root.join("index.db");
15337        let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
15338        let mut sql = String::from(
15339            "CREATE TABLE ids (id TEXT);
15340             CREATE TABLE names (name TEXT);
15341             CREATE TABLE monikers (moniker TEXT);
15342             CREATE TABLE versions (version TEXT);
15343             CREATE TABLE channels (channel TEXT);
15344             CREATE TABLE pathparts (parent INT64, pathpart TEXT);
15345             CREATE TABLE manifest (
15346                 id INT64,
15347                 name INT64,
15348                 moniker INT64,
15349                 version INT64,
15350                 channel INT64,
15351                 pathpart INT64
15352             );
15353             INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
15354             INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
15355             INSERT INTO channels(rowid, channel) VALUES (1, '');
15356             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
15357             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
15358        );
15359        for (index, version) in versions.iter().enumerate() {
15360            let rowid = index + 1;
15361            sql.push_str(&format!(
15362                "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
15363                 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
15364            ));
15365        }
15366        execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
15367        let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
15368            .expect("checkpoint preindexed V1 database");
15369        connection.cacheflush().expect("flush preindexed V1 database");
15370        drop(connection);
15371        let bytes = fs::read(&db_path).expect("read index bytes");
15372        let _ = fs::remove_dir_all(root);
15373        bytes
15374    }
15375
15376    fn test_manifest_yaml(version: &str) -> Vec<u8> {
15377        format!(
15378            "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
15379        )
15380        .into_bytes()
15381    }
15382
15383    fn show_test_package(version: Option<&str>) -> PackageQuery {
15384        PackageQuery {
15385            id: Some("Test.Package".to_owned()),
15386            source: Some("test-winget".to_owned()),
15387            exact: true,
15388            version: version.map(str::to_owned),
15389            ..PackageQuery::default()
15390        }
15391    }
15392
15393    #[test]
15394    fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
15395        let app_root = temp_app_root("preindexed_explicit_refresh");
15396        let server = TestHttpServer::start();
15397        server.set_status("/source2.msix", 404);
15398        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
15399        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
15400        let source = test_source_record(server.url());
15401        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15402        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15403
15404        let result = repository
15405            .show(&show_test_package(Some("2.0.0")))
15406            .expect("show after refresh");
15407
15408        assert_eq!(result.manifest.version, "2.0.0");
15409        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
15410        let _ = fs::remove_dir_all(app_root);
15411    }
15412
15413    #[test]
15414    fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
15415        let app_root = temp_app_root("preindexed_explicit_still_missing");
15416        let server = TestHttpServer::start();
15417        server.set_status("/source2.msix", 404);
15418        server.set_source_package(make_source_package(&["1.0.0"]), "same");
15419        let source = test_source_record(server.url());
15420        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15421        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15422
15423        let error = repository
15424            .show(&show_test_package(Some("2.0.0")))
15425            .expect_err("missing explicit version must fail");
15426
15427        assert!(format!("{error:#}").contains("2.0.0"));
15428        let _ = fs::remove_dir_all(app_root);
15429    }
15430
15431    #[test]
15432    fn preindexed_stale_index_refreshes_for_latest_requests() {
15433        let app_root = temp_app_root("preindexed_ttl_refresh");
15434        let server = TestHttpServer::start();
15435        server.set_status("/source2.msix", 404);
15436        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15437        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
15438        let mut source = test_source_record(server.url());
15439        source.last_update = Some(Utc::now() - Duration::days(2));
15440        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15441        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15442
15443        let result = repository
15444            .show(&show_test_package(None))
15445            .expect("show latest after TTL refresh");
15446
15447        assert_eq!(result.manifest.version, "2.0.0");
15448        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
15449        let _ = fs::remove_dir_all(app_root);
15450    }
15451
15452    #[test]
15453    fn preindexed_stale_index_not_modified_keeps_local_index() {
15454        let app_root = temp_app_root("preindexed_ttl_not_modified");
15455        let server = TestHttpServer::start();
15456        server.set_status("/source2.msix", 404);
15457        server.set_source_package_with_validators(
15458            make_source_package(&["1.0.0", "2.0.0"]),
15459            "fresh",
15460            Some("\"same-index\""),
15461            Some("Wed, 03 Jun 2026 12:00:00 GMT"),
15462        );
15463        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15464        let mut source = test_source_record(server.url());
15465        source.last_update = Some(Utc::now() - Duration::days(2));
15466        source.etag = Some("\"same-index\"".to_owned());
15467        source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
15468        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15469        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15470
15471        let result = repository
15472            .show(&show_test_package(None))
15473            .expect("show latest from not-modified stale index");
15474
15475        let refreshed_source = &repository.list_sources()[0];
15476        assert_eq!(result.manifest.version, "1.0.0");
15477        assert_eq!(server.request_count("/source.msix"), 1);
15478        assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\""));
15479        assert_eq!(
15480            refreshed_source.last_modified.as_deref(),
15481            Some("Wed, 03 Jun 2026 12:00:00 GMT")
15482        );
15483        let _ = fs::remove_dir_all(app_root);
15484    }
15485
15486    #[test]
15487    fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
15488        let app_root = temp_app_root("preindexed_fresh_mtime");
15489        let server = TestHttpServer::start();
15490        server.set_status("/source2.msix", 404);
15491        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15492        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15493        let mut source = test_source_record(server.url());
15494        source.last_update = Some(Utc::now() - Duration::days(2));
15495        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15496        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
15497
15498        let result = repository
15499            .show(&show_test_package(None))
15500            .expect("show latest from fresh index mtime");
15501
15502        assert_eq!(result.manifest.version, "1.0.0");
15503        assert_eq!(server.request_count("/source.msix"), 0);
15504        let _ = fs::remove_dir_all(app_root);
15505    }
15506
15507    #[test]
15508    fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
15509        let app_root = temp_app_root("preindexed_ttl_fallback");
15510        let server = TestHttpServer::start();
15511        server.set_status("/source2.msix", 404);
15512        server.set_status("/source.msix", 404);
15513        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15514        let mut source = test_source_record(server.url());
15515        source.last_update = Some(Utc::now() - Duration::days(2));
15516        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15517        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15518
15519        let result = repository
15520            .show(&show_test_package(None))
15521            .expect("show latest from stale cache after refresh failure");
15522
15523        assert_eq!(result.manifest.version, "1.0.0");
15524        let _ = fs::remove_dir_all(app_root);
15525    }
15526
15527    #[test]
15528    fn explicit_source_search_failure_is_not_reported_as_no_match() {
15529        let app_root = temp_app_root("preindexed_source_search_failure");
15530        let server = TestHttpServer::start();
15531        server.set_status("/source2.msix", 404);
15532        server.set_status("/source.msix", 404);
15533        let source = test_source_record(server.url());
15534        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15535
15536        let error = repository
15537            .show(&show_test_package(None))
15538            .expect_err("source failure must surface");
15539        let message = format!("{error:#}");
15540
15541        assert!(message.contains("failed to search source 'test-winget'"));
15542        assert!(message.contains("source.msix"));
15543        assert!(!message.contains("no package matched the supplied query"));
15544        let _ = fs::remove_dir_all(app_root);
15545    }
15546
15547    #[test]
15548    fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
15549        let app_root = temp_app_root("preindexed_no_double_refresh");
15550        let server = TestHttpServer::start();
15551        server.set_status("/source2.msix", 404);
15552        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15553        let mut source = test_source_record(server.url());
15554        source.last_update = Some(Utc::now() - Duration::days(2));
15555        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15556        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15557
15558        let error = repository
15559            .show(&show_test_package(Some("3.0.0")))
15560            .expect_err("still-missing explicit version must fail after one stale refresh");
15561
15562        assert!(format!("{error:#}").contains("3.0.0"));
15563        assert_eq!(server.request_count("/source.msix"), 1);
15564        let _ = fs::remove_dir_all(app_root);
15565    }
15566
15567    #[test]
15568    fn select_v2_version_without_explicit_filters_returns_latest() {
15569        let versions = vec![
15570            version_entry("1.0.0", None, None),
15571            version_entry("1.2.0", None, None),
15572            version_entry("1.1.0", None, None),
15573        ];
15574
15575        let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
15576
15577        assert_eq!(selected.version, "1.2.0");
15578    }
15579
15580    #[test]
15581    fn select_v2_version_with_missing_requested_version_throws() {
15582        let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
15583
15584        let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
15585            .expect_err("missing requested version");
15586
15587        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15588        assert!(error.to_string().contains("9.9.9"));
15589    }
15590
15591    #[test]
15592    fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
15593        let versions = vec![version_entry("1.2.0", None, None)];
15594
15595        let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15596            .expect_err("unsupported channel request");
15597
15598        assert!(error.to_string().contains("beta"));
15599        assert!(error.to_string().contains("channel metadata"));
15600    }
15601
15602    #[test]
15603    fn select_v1_version_channel_only_returns_latest_matching_channel() {
15604        let versions = vec![
15605            v1_version("1.2.0", "stable"),
15606            v1_version("1.3.0", "stable"),
15607            v1_version("2.0.0", "preview"),
15608        ];
15609
15610        let selected =
15611            select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
15612
15613        assert_eq!(selected.version, "1.3.0");
15614    }
15615
15616    #[test]
15617    fn select_rest_version_channel_mismatch_throws() {
15618        let versions = vec![
15619            VersionKey {
15620                version: "1.2.0".to_owned(),
15621                channel: "stable".to_owned(),
15622            },
15623            VersionKey {
15624                version: "1.1.0".to_owned(),
15625                channel: "beta".to_owned(),
15626            },
15627        ];
15628
15629        let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15630            .expect_err("missing version and channel pair");
15631
15632        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15633    }
15634
15635    #[test]
15636    fn map_arp_version_returns_catalog_version_inside_range() {
15637        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
15638        // `10.1.826.23019`. compare_version on that against the catalog Version
15639        // `10.0.108` claims installed > available, so without this mapping the
15640        // upgrade was silently dropped.
15641        let entries = vec![
15642            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15643            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15644            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
15645        ];
15646
15647        assert_eq!(
15648            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
15649            Some("10.0.108")
15650        );
15651    }
15652
15653    #[test]
15654    fn map_arp_version_returns_none_when_no_range_matches() {
15655        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
15656        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
15657        // original installed version (and accept that no upgrade can be
15658        // computed) — same as winget's behavior.
15659        let entries = vec![
15660            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15661            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15662        ];
15663
15664        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
15665    }
15666
15667    #[test]
15668    fn map_arp_version_reports_less_than_latest_for_coarse_display_version() {
15669        // Snagit 2025 reports ARP DisplayVersion `25.4.1`, but the catalog's
15670        // latest ARP build is `25.4.1.10325`. winget renders this as
15671        // installed `< 25.4.1` so the latest installer remains applicable.
15672        let entries = vec![
15673            version_entry("25.4.1", Some("25.4.1.10325"), Some("25.4.1.10325")),
15674            version_entry("25.4.0", Some("25.4.0"), Some("25.4.0.8498")),
15675        ];
15676
15677        assert!(map_arp_version_to_catalog(&entries, "25.4.1").is_none());
15678        assert_eq!(
15679            less_than_latest_arp_anchored_version(&entries, "25.4.1").as_deref(),
15680            Some("< 25.4.1")
15681        );
15682        assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none());
15683    }
15684
15685    #[test]
15686    fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() {
15687        // WindowsAppRuntime packages can be serviced beyond the latest winget
15688        // catalog versionData range. winget renders those as `> latest` and
15689        // does not report an available downgrade.
15690        let entries = vec![
15691            version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")),
15692            version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")),
15693        ];
15694
15695        assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none());
15696        assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none());
15697        assert_eq!(
15698            greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(),
15699            Some("> 1.5.8")
15700        );
15701        assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none());
15702
15703        let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))];
15704        assert!(
15705            greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none()
15706        );
15707    }
15708
15709    #[test]
15710    fn map_arp_version_skips_entries_missing_arp_bounds() {
15711        // Older catalog packages predate AppsAndFeaturesEntries and have no
15712        // aMiV/aMaV. Those entries must be ignored — not silently treated as
15713        // an unbounded range.
15714        let entries = vec![
15715            version_entry("9.0.300", None, None),
15716            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
15717        ];
15718
15719        assert_eq!(
15720            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
15721            Some("9.0.117")
15722        );
15723        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
15724    }
15725
15726    #[test]
15727    fn map_arp_version_handles_inclusive_range_endpoints() {
15728        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
15729        // must still map.
15730        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
15731
15732        assert_eq!(
15733            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
15734            Some("3.14.5")
15735        );
15736        assert_eq!(
15737            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
15738            Some("3.14.5")
15739        );
15740        assert_eq!(
15741            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
15742            Some("3.14.5")
15743        );
15744    }
15745
15746    #[test]
15747    fn map_arp_version_ignores_unknown_marker() {
15748        // collect_installed_packages writes the literal "Unknown" when ARP has
15749        // no DisplayVersion. Trying to map that would falsely match the first
15750        // entry whose aMiV..aMaV string-compares to include "Unknown".
15751        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
15752
15753        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
15754        assert!(map_arp_version_to_catalog(&entries, "").is_none());
15755    }
15756
15757    #[test]
15758    fn latest_arp_anchored_skips_internal_versions() {
15759        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
15760        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
15761        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
15762        // it against an installed canonical of `1.8.6` would falsely report
15763        // an upgrade. Returns the highest anchored Version instead.
15764        let entries = vec![
15765            version_entry("8000.836.2153.0", None, None),
15766            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
15767            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
15768            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
15769        ];
15770
15771        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
15772    }
15773
15774    #[test]
15775    fn latest_arp_anchored_returns_none_without_bounds() {
15776        // App Installer's versionData has no aMiV/aMaV — every version is a
15777        // user-facing MSIX Version. Caller must fall back to
15778        // `packages.latest_version` in this case.
15779        let entries = vec![
15780            version_entry("1.28.240.0", None, None),
15781            version_entry("1.27.470.0", None, None),
15782        ];
15783
15784        assert!(latest_arp_anchored_version(&entries).is_none());
15785    }
15786
15787    #[test]
15788    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
15789        let package = InstalledPackage {
15790            name: "AzCopy v10".to_owned(),
15791            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
15792            installed_version: "10.32.2".to_owned(),
15793            publisher: Some("Microsoft".to_owned()),
15794            scope: Some("Machine".to_owned()),
15795            installer_category: Some("exe".to_owned()),
15796            install_location: None,
15797            package_family_names: Vec::new(),
15798            product_codes: Vec::new(),
15799            upgrade_codes: Vec::new(),
15800            correlated: Some(SearchMatch {
15801                source_name: "winget".to_owned(),
15802                source_kind: SourceKind::PreIndexed,
15803                id: "Microsoft.Azure.AZCopy.10".to_owned(),
15804                name: "AzCopy v10".to_owned(),
15805                moniker: None,
15806                version: Some("10.32.3".to_owned()),
15807                channel: None,
15808                match_criteria: None,
15809            }),
15810            winget_package_identifier: None,
15811            winget_source_identifier: None,
15812            installed_version_canonical: false,
15813            correlated_requires_explicit_upgrade: false,
15814            correlated_lacks_compatible_installer: false,
15815        };
15816
15817        let item = list_match_from_installed(package, &[]);
15818
15819        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
15820        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
15821        assert_eq!(item.source_name.as_deref(), Some("winget"));
15822        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
15823    }
15824
15825    #[test]
15826    fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() {
15827        let rows = vec![
15828            ListMatch {
15829                name: "Example 1.0".to_owned(),
15830                id: "Example.Tool".to_owned(),
15831                local_id: r"ARP\Machine\X64\old".to_owned(),
15832                installed_version: "1.0.0".to_owned(),
15833                available_version: Some("3.0.0".to_owned()),
15834                source_name: Some("winget".to_owned()),
15835                publisher: None,
15836                scope: None,
15837                installer_category: None,
15838                install_location: None,
15839                package_family_names: Vec::new(),
15840                product_codes: Vec::new(),
15841                upgrade_codes: Vec::new(),
15842            },
15843            ListMatch {
15844                name: "Example 2.0".to_owned(),
15845                id: "Example.Tool".to_owned(),
15846                local_id: r"ARP\Machine\X64\new".to_owned(),
15847                installed_version: "2.0.0".to_owned(),
15848                available_version: Some("3.0.0".to_owned()),
15849                source_name: Some("winget".to_owned()),
15850                publisher: None,
15851                scope: None,
15852                installer_category: None,
15853                install_location: None,
15854                package_family_names: Vec::new(),
15855                product_codes: Vec::new(),
15856                upgrade_codes: Vec::new(),
15857            },
15858            ListMatch {
15859                name: "Other".to_owned(),
15860                id: "Other.Tool".to_owned(),
15861                local_id: r"ARP\Machine\X64\other".to_owned(),
15862                installed_version: "1.0.0".to_owned(),
15863                available_version: Some("2.0.0".to_owned()),
15864                source_name: Some("winget".to_owned()),
15865                publisher: None,
15866                scope: None,
15867                installer_category: None,
15868                install_location: None,
15869                package_family_names: Vec::new(),
15870                product_codes: Vec::new(),
15871                upgrade_codes: Vec::new(),
15872            },
15873        ];
15874
15875        let result = suppress_duplicate_available_versions(rows);
15876
15877        assert_eq!(result[0].available_version, None);
15878        assert_eq!(result[1].available_version.as_deref(), Some("3.0.0"));
15879        assert_eq!(result[2].available_version.as_deref(), Some("2.0.0"));
15880    }
15881
15882    #[test]
15883    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
15884        let package = InstalledPackage {
15885            name: "Atlassian CLI".to_owned(),
15886            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15887            installed_version: "1.3.18-stable".to_owned(),
15888            publisher: Some("Atlassian".to_owned()),
15889            scope: Some("User".to_owned()),
15890            installer_category: Some("exe".to_owned()),
15891            install_location: None,
15892            package_family_names: Vec::new(),
15893            product_codes: Vec::new(),
15894            upgrade_codes: Vec::new(),
15895            correlated: Some(SearchMatch {
15896                source_name: "winget".to_owned(),
15897                source_kind: SourceKind::PreIndexed,
15898                id: "Atlassian.AtlassianCLI".to_owned(),
15899                name: "Atlassian CLI".to_owned(),
15900                moniker: None,
15901                version: Some("1.3.18-stable".to_owned()),
15902                channel: None,
15903                match_criteria: Some("ProductCode".to_owned()),
15904            }),
15905            winget_package_identifier: None,
15906            winget_source_identifier: None,
15907            installed_version_canonical: false,
15908            correlated_requires_explicit_upgrade: false,
15909            correlated_lacks_compatible_installer: false,
15910        };
15911
15912        let item = list_match_from_installed(package, &[]);
15913
15914        assert_eq!(item.id, "Atlassian.AtlassianCLI");
15915        assert_eq!(
15916            item.local_id,
15917            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
15918        );
15919        assert_ne!(item.id, item.local_id);
15920        assert_eq!(item.source_name.as_deref(), Some("winget"));
15921    }
15922
15923    #[test]
15924    fn list_match_from_installed_derives_winget_portable_identity_from_local_id() {
15925        let package = InstalledPackage {
15926            name: "FFmpeg".to_owned(),
15927            local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15928            installed_version: "8.0".to_owned(),
15929            publisher: Some("Gyan".to_owned()),
15930            scope: Some("User".to_owned()),
15931            installer_category: Some("portable".to_owned()),
15932            install_location: None,
15933            package_family_names: Vec::new(),
15934            product_codes: Vec::new(),
15935            upgrade_codes: Vec::new(),
15936            correlated: None,
15937            winget_package_identifier: None,
15938            winget_source_identifier: None,
15939            installed_version_canonical: false,
15940            correlated_requires_explicit_upgrade: false,
15941            correlated_lacks_compatible_installer: false,
15942        };
15943        let sources = vec![SourceRecord {
15944            name: "winget".to_owned(),
15945            kind: SourceKind::PreIndexed,
15946            arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
15947            identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15948            trust_level: "Trusted".to_owned(),
15949            explicit: false,
15950            priority: 0,
15951            last_update: None,
15952            source_version: None,
15953            etag: None,
15954            last_modified: None,
15955        }];
15956
15957        let item = list_match_from_installed(package, &sources);
15958
15959        assert_eq!(item.id, "Gyan.FFmpeg");
15960        assert_eq!(
15961            item.local_id,
15962            r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe"
15963        );
15964        assert_eq!(item.source_name.as_deref(), Some("winget"));
15965    }
15966
15967    #[test]
15968    fn list_match_from_installed_uses_winget_arp_identity_metadata() {
15969        let package = InstalledPackage {
15970            name: "tessl".to_owned(),
15971            local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15972            installed_version: "0.77.0".to_owned(),
15973            publisher: Some("tessl.io".to_owned()),
15974            scope: Some("User".to_owned()),
15975            installer_category: Some("portable".to_owned()),
15976            install_location: None,
15977            package_family_names: Vec::new(),
15978            product_codes: Vec::new(),
15979            upgrade_codes: Vec::new(),
15980            correlated: None,
15981            winget_package_identifier: Some("tessl.tessl".to_owned()),
15982            winget_source_identifier: Some("api.winget.pro".to_owned()),
15983            installed_version_canonical: false,
15984            correlated_requires_explicit_upgrade: false,
15985            correlated_lacks_compatible_installer: false,
15986        };
15987
15988        let item = list_match_from_installed(package, &[]);
15989
15990        assert_eq!(item.id, "tessl.tessl");
15991        assert_eq!(item.source_name.as_deref(), Some("api.winget.pro"));
15992    }
15993
15994    #[test]
15995    fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() {
15996        let package = InstalledPackage {
15997            name: "tessl".to_owned(),
15998            local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15999            installed_version: "0.77.0".to_owned(),
16000            publisher: Some("tessl.io".to_owned()),
16001            scope: Some("User".to_owned()),
16002            installer_category: Some("portable".to_owned()),
16003            install_location: None,
16004            package_family_names: Vec::new(),
16005            product_codes: Vec::new(),
16006            upgrade_codes: Vec::new(),
16007            correlated: None,
16008            winget_package_identifier: Some("tessl.tessl".to_owned()),
16009            winget_source_identifier: Some("api.winget.pro".to_owned()),
16010            installed_version_canonical: false,
16011            correlated_requires_explicit_upgrade: false,
16012            correlated_lacks_compatible_installer: false,
16013        };
16014        let sources = vec![SourceRecord {
16015            name: "winget.pro".to_owned(),
16016            kind: SourceKind::Rest,
16017            arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(),
16018            identifier: "api.winget.pro".to_owned(),
16019            trust_level: "Trusted".to_owned(),
16020            explicit: false,
16021            priority: 0,
16022            last_update: None,
16023            source_version: None,
16024            etag: None,
16025            last_modified: None,
16026        }];
16027
16028        let item = list_match_from_installed(package, &sources);
16029
16030        assert_eq!(item.id, "tessl.tessl");
16031        assert_eq!(item.source_name.as_deref(), Some("winget.pro"));
16032    }
16033
16034    #[test]
16035    fn list_match_from_installed_keeps_scanning_portable_source_identifiers() {
16036        let package = InstalledPackage {
16037            name: "Example".to_owned(),
16038            local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(),
16039            installed_version: "1.0".to_owned(),
16040            publisher: Some("Vendor".to_owned()),
16041            scope: Some("User".to_owned()),
16042            installer_category: Some("portable".to_owned()),
16043            install_location: None,
16044            package_family_names: Vec::new(),
16045            product_codes: Vec::new(),
16046            upgrade_codes: Vec::new(),
16047            correlated: None,
16048            winget_package_identifier: None,
16049            winget_source_identifier: None,
16050            installed_version_canonical: false,
16051            correlated_requires_explicit_upgrade: false,
16052            correlated_lacks_compatible_installer: false,
16053        };
16054        let sources = vec![
16055            SourceRecord {
16056                name: "long".to_owned(),
16057                kind: SourceKind::PreIndexed,
16058                arg: "https://example.invalid/long".to_owned(),
16059                identifier: "identifier-longer-than-local-id".to_owned(),
16060                trust_level: "Trusted".to_owned(),
16061                explicit: false,
16062                priority: 0,
16063                last_update: None,
16064                source_version: None,
16065                etag: None,
16066                last_modified: None,
16067            },
16068            SourceRecord {
16069                name: "short".to_owned(),
16070                kind: SourceKind::PreIndexed,
16071                arg: "https://example.invalid/short".to_owned(),
16072                identifier: "short".to_owned(),
16073                trust_level: "Trusted".to_owned(),
16074                explicit: false,
16075                priority: 1,
16076                last_update: None,
16077                source_version: None,
16078                etag: None,
16079                last_modified: None,
16080            },
16081        ];
16082
16083        let item = list_match_from_installed(package, &sources);
16084
16085        assert_eq!(item.id, "Vendor.Example");
16086        assert_eq!(item.source_name.as_deref(), Some("short"));
16087    }
16088
16089    #[test]
16090    fn list_response_serialization_keeps_pascal_case_fields() {
16091        let response = ListResponse {
16092            matches: vec![ListMatch {
16093                name: "PowerToys".to_owned(),
16094                id: "Microsoft.PowerToys".to_owned(),
16095                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
16096                installed_version: "0.98.1".to_owned(),
16097                available_version: Some("0.99.0".to_owned()),
16098                source_name: Some("winget".to_owned()),
16099                publisher: Some("Microsoft".to_owned()),
16100                scope: Some("Machine".to_owned()),
16101                installer_category: Some("msi".to_owned()),
16102                install_location: None,
16103                package_family_names: Vec::new(),
16104                product_codes: Vec::new(),
16105                upgrade_codes: Vec::new(),
16106            }],
16107            warnings: Vec::new(),
16108            truncated: false,
16109        };
16110
16111        let value = serde_json::to_value(&response).expect("serialize list response");
16112        let match_value = &value["Matches"][0];
16113
16114        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
16115        assert_eq!(match_value["InstalledVersion"], "0.98.1");
16116        assert_eq!(match_value["AvailableVersion"], "0.99.0");
16117        assert_eq!(match_value["SourceName"], "winget");
16118        assert!(match_value.get("local_id").is_none());
16119        assert!(match_value.get("installed_version").is_none());
16120        assert!(match_value.get("available_version").is_none());
16121        assert!(match_value.get("source_name").is_none());
16122    }
16123
16124    #[test]
16125    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
16126        let response = SearchResponse {
16127            matches: vec![SearchMatch {
16128                source_name: "msstore".to_owned(),
16129                source_kind: SourceKind::Rest,
16130                id: "9WZDNCRFJBMP".to_owned(),
16131                name: "Microsoft To Do".to_owned(),
16132                moniker: None,
16133                version: Some("2.123.456.0".to_owned()),
16134                channel: None,
16135                match_criteria: None,
16136            }],
16137            warnings: Vec::new(),
16138            truncated: false,
16139        };
16140
16141        let value = serde_json::to_value(&response).expect("serialize search response");
16142        let match_value = &value["Matches"][0];
16143
16144        assert_eq!(match_value["Name"], "Microsoft To Do");
16145        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
16146        assert_eq!(match_value["Version"], "2.123.456.0");
16147        assert_eq!(match_value["SourceName"], "msstore");
16148        assert!(match_value.get("source_name").is_none());
16149    }
16150
16151    #[test]
16152    fn versions_result_serialization_keeps_pascal_case_fields() {
16153        let result = VersionsResult {
16154            package: SearchMatch {
16155                source_name: "winget".to_owned(),
16156                source_kind: SourceKind::PreIndexed,
16157                id: "Microsoft.PowerToys".to_owned(),
16158                name: "PowerToys".to_owned(),
16159                moniker: None,
16160                version: Some("0.99.0".to_owned()),
16161                channel: None,
16162                match_criteria: None,
16163            },
16164            versions: vec![
16165                VersionKey {
16166                    version: "0.99.0".to_owned(),
16167                    channel: "stable".to_owned(),
16168                },
16169                VersionKey {
16170                    version: "0.100.0-preview".to_owned(),
16171                    channel: "preview".to_owned(),
16172                },
16173            ],
16174            warnings: Vec::new(),
16175        };
16176
16177        let value = serde_json::to_value(&result).expect("serialize versions result");
16178
16179        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
16180        assert_eq!(value["Versions"][0]["Channel"], "stable");
16181        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
16182        assert_eq!(value["Versions"][1]["Channel"], "preview");
16183        assert!(value.get("versions").is_none());
16184    }
16185
16186    #[test]
16187    fn version_data_parses_arp_bounds_from_winget_payload() {
16188        // Sanity check that aMiV/aMaV deserialize from a real-world
16189        // versionData.mszyml payload shape. Without `default`, packages
16190        // predating AppsAndFeaturesEntries would fail to parse.
16191        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";
16192
16193        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
16194        assert_eq!(document.versions.len(), 2);
16195        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
16196        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
16197        assert!(document.versions[1].arp_min_version.is_none());
16198        assert!(document.versions[1].arp_max_version.is_none());
16199    }
16200
16201    #[test]
16202    fn msix_resource_string_name_resolves_to_catalog_name() {
16203        // App Installer's MSIX manifest stores DisplayName as
16204        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
16205        // know the catalog calls it `App Installer` — show that instead of
16206        // the unresolved placeholder, matching winget's output.
16207        let mut package = InstalledPackage {
16208            name: "ms-resource:appDisplayName".to_owned(),
16209            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
16210            installed_version: "1.28.239.0".to_owned(),
16211            publisher: None,
16212            scope: Some("User".to_owned()),
16213            installer_category: Some("msix".to_owned()),
16214            install_location: None,
16215            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
16216            product_codes: Vec::new(),
16217            upgrade_codes: Vec::new(),
16218            correlated: Some(SearchMatch {
16219                source_name: "winget".to_owned(),
16220                source_kind: SourceKind::PreIndexed,
16221                id: "Microsoft.AppInstaller".to_owned(),
16222                name: "App Installer".to_owned(),
16223                moniker: None,
16224                version: Some("1.28.240.0".to_owned()),
16225                channel: None,
16226                match_criteria: Some("PackageFamilyName".to_owned()),
16227            }),
16228            winget_package_identifier: None,
16229            winget_source_identifier: None,
16230            installed_version_canonical: false,
16231            correlated_requires_explicit_upgrade: false,
16232            correlated_lacks_compatible_installer: false,
16233        };
16234        apply_msix_resource_string_name_fix(&mut package);
16235        assert_eq!(package.name, "App Installer");
16236    }
16237
16238    #[test]
16239    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
16240        // ARP rows can also carry unusual display names; the fix is gated
16241        // on the local_id prefix so a `ms-resource:` literal that somehow
16242        // appears in an ARP DisplayName (extremely unlikely, but possible)
16243        // doesn't get silently rewritten.
16244        let mut package = InstalledPackage {
16245            name: "ms-resource:appDisplayName".to_owned(),
16246            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
16247            installed_version: "1.0".to_owned(),
16248            publisher: None,
16249            scope: Some("Machine".to_owned()),
16250            installer_category: Some("msi".to_owned()),
16251            install_location: None,
16252            package_family_names: Vec::new(),
16253            product_codes: Vec::new(),
16254            upgrade_codes: Vec::new(),
16255            correlated: Some(SearchMatch {
16256                source_name: "winget".to_owned(),
16257                source_kind: SourceKind::PreIndexed,
16258                id: "Some.Package".to_owned(),
16259                name: "Should Not Apply".to_owned(),
16260                moniker: None,
16261                version: Some("1.0".to_owned()),
16262                channel: None,
16263                match_criteria: None,
16264            }),
16265            winget_package_identifier: None,
16266            winget_source_identifier: None,
16267            installed_version_canonical: false,
16268            correlated_requires_explicit_upgrade: false,
16269            correlated_lacks_compatible_installer: false,
16270        };
16271        apply_msix_resource_string_name_fix(&mut package);
16272        assert_eq!(package.name, "ms-resource:appDisplayName");
16273    }
16274
16275    #[test]
16276    fn msix_resource_string_fix_skips_resolved_names() {
16277        // The vast majority of MSIX entries have already-resolved names
16278        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
16279        // those — `meta.name` and the installed name may legitimately
16280        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
16281        let mut package = InstalledPackage {
16282            name: "Microsoft Teams".to_owned(),
16283            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
16284            installed_version: "25290.205.4069.4894".to_owned(),
16285            publisher: None,
16286            scope: Some("User".to_owned()),
16287            installer_category: Some("msix".to_owned()),
16288            install_location: None,
16289            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
16290            product_codes: Vec::new(),
16291            upgrade_codes: Vec::new(),
16292            correlated: Some(SearchMatch {
16293                source_name: "winget".to_owned(),
16294                source_kind: SourceKind::PreIndexed,
16295                id: "Microsoft.Teams".to_owned(),
16296                name: "Microsoft Teams Catalog Name".to_owned(),
16297                moniker: None,
16298                version: Some("26106.1906.4665.7308".to_owned()),
16299                channel: None,
16300                match_criteria: Some("PackageFamilyName".to_owned()),
16301            }),
16302            winget_package_identifier: None,
16303            winget_source_identifier: None,
16304            installed_version_canonical: false,
16305            correlated_requires_explicit_upgrade: false,
16306            correlated_lacks_compatible_installer: false,
16307        };
16308        apply_msix_resource_string_name_fix(&mut package);
16309        assert_eq!(package.name, "Microsoft Teams");
16310    }
16311
16312    #[cfg(windows)]
16313    #[test]
16314    fn unflip_packed_guid_reverses_msi_installer_packing() {
16315        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
16316        // chunks (8/4/4/2*8). This is the encoding used inside
16317        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
16318        // and their value names. Verified against the user's installed
16319        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
16320        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
16321        assert_eq!(
16322            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
16323            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
16324        );
16325        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
16326        // form `A3A70C74FE2431248AD5F8A59570C782`.
16327        assert_eq!(
16328            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
16329            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
16330        );
16331        // Reject non-32-char or non-hex inputs.
16332        assert!(unflip_packed_guid("nothex").is_none());
16333        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
16334        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
16335    }
16336
16337    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
16338        InstalledPackage {
16339            name: format!("{id} install"),
16340            local_id: format!(r"ARP\Machine\X64\{id}"),
16341            installed_version: installed_version.to_owned(),
16342            publisher: None,
16343            scope: Some("Machine".to_owned()),
16344            installer_category: Some("msi".to_owned()),
16345            install_location: None,
16346            package_family_names: Vec::new(),
16347            product_codes: Vec::new(),
16348            upgrade_codes: Vec::new(),
16349            correlated: Some(SearchMatch {
16350                source_name: "winget".to_owned(),
16351                source_kind: SourceKind::PreIndexed,
16352                id: id.to_owned(),
16353                name: id.to_owned(),
16354                moniker: None,
16355                version: Some("99.0.0".to_owned()),
16356                channel: None,
16357                match_criteria: None,
16358            }),
16359            winget_package_identifier: None,
16360            winget_source_identifier: None,
16361            installed_version_canonical: canonical,
16362            correlated_requires_explicit_upgrade: false,
16363            correlated_lacks_compatible_installer: false,
16364        }
16365    }
16366
16367    #[test]
16368    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
16369        // Two installed entries correlate to the same catalog id. One had its
16370        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
16371        // the other did not (e.g. VS-installed .NET SDK whose ARP
16372        // `40.10.18029` doesn't fit any bucket). Without the canonical
16373        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
16374        // wrong row wins and the upgrade silently disappears.
16375        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
16376        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
16377
16378        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
16379        assert_eq!(result.len(), 1);
16380        assert_eq!(result[0].installed_version, "10.0.108");
16381        assert!(result[0].installed_version_canonical);
16382    }
16383
16384    #[test]
16385    fn dedupe_keeps_highest_among_canonical_rows() {
16386        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
16387        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
16388
16389        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
16390        assert_eq!(result.len(), 1);
16391        assert_eq!(result[0].installed_version, "1.7.9");
16392    }
16393
16394    #[test]
16395    fn dedupe_leaves_uncorrelated_rows_alone() {
16396        // Uncorrelated entries don't reach upgrade output anyway (the
16397        // upgrade filter requires correlation), but the dedupe pass must not
16398        // drop them — `pinget list` could share this code path in the future.
16399        let uncorrelated = InstalledPackage {
16400            name: "Foo".to_owned(),
16401            local_id: r"ARP\Machine\X64\Foo".to_owned(),
16402            installed_version: "1.0".to_owned(),
16403            publisher: None,
16404            scope: Some("Machine".to_owned()),
16405            installer_category: Some("exe".to_owned()),
16406            install_location: None,
16407            package_family_names: Vec::new(),
16408            product_codes: Vec::new(),
16409            upgrade_codes: Vec::new(),
16410            correlated: None,
16411            winget_package_identifier: None,
16412            winget_source_identifier: None,
16413            installed_version_canonical: false,
16414            correlated_requires_explicit_upgrade: false,
16415            correlated_lacks_compatible_installer: false,
16416        };
16417        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
16418        assert_eq!(result.len(), 1);
16419    }
16420}