Skip to main content

pinget_core/
lib.rs

1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fmt::{Display, Formatter};
6use std::fs::{self, OpenOptions};
7use std::io::{Cursor, Read, Write};
8#[cfg(windows)]
9use std::mem::{MaybeUninit, size_of};
10#[cfg(windows)]
11use std::os::windows::ffi::{OsStrExt, OsStringExt};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, OnceLock, RwLock};
14use std::time::{Duration as StdDuration, Instant};
15
16use anyhow::{Context, Result, anyhow, bail};
17use chrono::{DateTime, Duration, Utc};
18use reqwest::StatusCode;
19use reqwest::blocking::{Client, Response};
20use reqwest::header::{
21    ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE,
22    IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, RANGE,
23};
24use serde::{Deserialize, Serialize};
25use serde_json::{Map as JsonMap, Value as JsonValue};
26use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
27use sha2::{Digest, Sha256};
28use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
29#[cfg(windows)]
30use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
31#[cfg(windows)]
32use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
33#[cfg(windows)]
34use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
35#[cfg(windows)]
36use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
37#[cfg(windows)]
38use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
39#[cfg(windows)]
40use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
41#[cfg(windows)]
42use winreg::{RegKey, enums::*};
43use zip::ZipArchive;
44
45const DEFAULT_MARKET: &str = "US";
46const DEFAULT_MAX_RESULTS: usize = 50;
47const LIST_LOOKUP_MAX_RESULTS: usize = 500;
48const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
49const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
50const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15;
51const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
52const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json";
53const APP_ROOT_ENV_VAR: &str = "PINGET_APPROOT";
54const SOURCE_MODE_ENV_VAR: &str = "PINGET_SOURCE_MODE";
55#[cfg(windows)]
56const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
57#[cfg(windows)]
58const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
59const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
60    "Installed package discovery is not supported on this platform; returning no installed packages.";
61const INSTALL_UNSUPPORTED_WARNING: &str =
62    "Installing packages is not supported on this platform; no changes were made.";
63const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
64const REPAIR_REINSTALL_WARNING: &str =
65    "Pinget repair currently re-runs the package install flow for the selected package.";
66const UNINSTALL_UNSUPPORTED_WARNING: &str =
67    "Uninstalling packages is not supported on this platform; no changes were made.";
68#[cfg(windows)]
69const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
70const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
71    "LocalManifestFiles",
72    "BypassCertificatePinningForMicrosoftStore",
73    "InstallerHashOverride",
74    "LocalArchiveMalwareScanOverride",
75    "ProxyCommandLineOptions",
76];
77const REST_SUPPORTED_CONTRACTS: &[&str] = &[
78    "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",
79];
80
81#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
82pub enum SourceKind {
83    #[serde(rename = "PreIndexed", alias = "preIndexed")]
84    PreIndexed,
85    #[serde(rename = "Rest", alias = "rest")]
86    Rest,
87}
88
89impl Display for SourceKind {
90    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
91        match self {
92            SourceKind::PreIndexed => f.write_str("preindexed"),
93            SourceKind::Rest => f.write_str("rest"),
94        }
95    }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SourceRecord {
100    pub name: String,
101    pub kind: SourceKind,
102    pub arg: String,
103    pub identifier: String,
104    #[serde(default = "default_source_trust_level")]
105    pub trust_level: String,
106    #[serde(default)]
107    pub explicit: bool,
108    #[serde(default)]
109    pub priority: i32,
110    #[serde(default)]
111    pub last_update: Option<DateTime<Utc>>,
112    #[serde(default)]
113    pub source_version: Option<String>,
114    #[serde(default)]
115    pub etag: Option<String>,
116    #[serde(default)]
117    pub last_modified: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121struct SourceStore {
122    sources: Vec<SourceRecord>,
123}
124
125impl Default for SourceStore {
126    fn default() -> Self {
127        Self {
128            sources: vec![
129                SourceRecord {
130                    name: "winget".to_owned(),
131                    kind: SourceKind::PreIndexed,
132                    arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
133                    identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
134                    trust_level: "Trusted".to_owned(),
135                    explicit: false,
136                    priority: 0,
137                    last_update: None,
138                    source_version: None,
139                    etag: None,
140                    last_modified: None,
141                },
142                SourceRecord {
143                    name: "msstore".to_owned(),
144                    kind: SourceKind::Rest,
145                    arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
146                    identifier: "StoreEdgeFD".to_owned(),
147                    trust_level: "Trusted".to_owned(),
148                    explicit: false,
149                    priority: 0,
150                    last_update: None,
151                    source_version: None,
152                    etag: None,
153                    last_modified: None,
154                },
155            ],
156        }
157    }
158}
159
160/// Options for embedding the WinGet core library in another application.
161#[derive(Debug, Clone)]
162pub struct RepositoryOptions {
163    pub app_root: PathBuf,
164    pub user_agent: String,
165    pub source_mode: SourceMode,
166    pub pre_indexed_source_auto_update_interval: Option<Duration>,
167    pub install_progress: Option<fn(&InstallProgress)>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum SourceMode {
172    Auto,
173    Private,
174    SystemWingetMirror,
175}
176
177impl SourceMode {
178    fn parse(value: &str) -> Option<Self> {
179        match value.trim().to_ascii_lowercase().as_str() {
180            "auto" => Some(Self::Auto),
181            "private" => Some(Self::Private),
182            "system-winget-mirror" | "systemwingetmirror" => Some(Self::SystemWingetMirror),
183            _ => None,
184        }
185    }
186
187    fn from_env() -> Option<Self> {
188        Self::parse(&std::env::var(SOURCE_MODE_ENV_VAR).ok()?)
189    }
190
191    fn resolve(from_env: Option<Self>, has_app_root_override: bool) -> Self {
192        from_env.unwrap_or(if has_app_root_override {
193            Self::Private
194        } else {
195            Self::Auto
196        })
197    }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201enum EffectiveSourceMode {
202    Private,
203    SystemWingetDirect,
204    SystemWingetMirror,
205}
206
207impl RepositoryOptions {
208    /// Creates library options that keep all persistent state under the supplied root.
209    pub fn new(app_root: impl Into<PathBuf>) -> Self {
210        Self {
211            app_root: app_root.into(),
212            user_agent: DEFAULT_USER_AGENT.to_owned(),
213            source_mode: SourceMode::Private,
214            pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
215            install_progress: None,
216        }
217    }
218
219    /// Uses the default per-user app-data root that the CLI also uses.
220    pub fn for_current_user() -> Result<Self> {
221        let source_mode = SourceMode::resolve(SourceMode::from_env(), std::env::var_os(APP_ROOT_ENV_VAR).is_some());
222        Ok(Self::new(default_app_root()?).with_source_mode(source_mode))
223    }
224
225    /// Overrides the HTTP user-agent sent to source endpoints.
226    #[must_use]
227    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
228        self.user_agent = user_agent.into();
229        self
230    }
231
232    #[must_use]
233    pub fn with_source_mode(mut self, source_mode: SourceMode) -> Self {
234        self.source_mode = source_mode;
235        self
236    }
237
238    /// Overrides automatic freshness checks for existing pre-indexed source indexes.
239    #[must_use]
240    pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
241        self.pre_indexed_source_auto_update_interval = interval;
242        self
243    }
244
245    /// Receives coarse install/update phase notifications. Intended for CLIs
246    /// and host applications that need to show progress while downloads or
247    /// silent installers are running.
248    #[must_use]
249    pub fn with_install_progress(mut self, progress: fn(&InstallProgress)) -> Self {
250        self.install_progress = Some(progress);
251        self
252    }
253}
254
255#[derive(Debug, Clone, Default)]
256pub struct PackageQuery {
257    pub query: Option<String>,
258    pub id: Option<String>,
259    pub name: Option<String>,
260    pub moniker: Option<String>,
261    pub tag: Option<String>,
262    pub command: Option<String>,
263    pub source: Option<String>,
264    pub count: Option<usize>,
265    pub exact: bool,
266    pub version: Option<String>,
267    pub channel: Option<String>,
268    pub locale: Option<String>,
269    pub installer_type: Option<String>,
270    pub installer_architecture: Option<String>,
271    pub platform: Option<String>,
272    pub os_version: Option<String>,
273    pub install_scope: Option<String>,
274}
275
276#[derive(Debug, Clone, Default)]
277pub struct ListQuery {
278    pub query: Option<String>,
279    pub id: Option<String>,
280    pub name: Option<String>,
281    pub moniker: Option<String>,
282    pub tag: Option<String>,
283    pub command: Option<String>,
284    pub product_code: Option<String>,
285    pub version: Option<String>,
286    pub source: Option<String>,
287    pub count: Option<usize>,
288    pub exact: bool,
289    pub install_scope: Option<String>,
290    pub upgrade_only: bool,
291    pub include_unknown: bool,
292    pub include_pinned: bool,
293}
294
295#[derive(Debug, Clone, serde::Serialize)]
296#[serde(rename_all = "PascalCase")]
297pub struct SearchMatch {
298    pub source_name: String,
299    pub source_kind: SourceKind,
300    pub id: String,
301    pub name: String,
302    pub moniker: Option<String>,
303    pub version: Option<String>,
304    pub channel: Option<String>,
305    pub match_criteria: Option<String>,
306}
307
308#[derive(Debug, Clone, serde::Serialize)]
309#[serde(rename_all = "PascalCase")]
310pub struct SearchResponse {
311    pub matches: Vec<SearchMatch>,
312    pub warnings: Vec<String>,
313    pub truncated: bool,
314}
315
316#[derive(Debug, Clone, serde::Serialize)]
317#[serde(rename_all = "PascalCase")]
318pub struct ListMatch {
319    pub name: String,
320    pub id: String,
321    pub local_id: String,
322    pub installed_version: String,
323    pub available_version: Option<String>,
324    pub source_name: Option<String>,
325    pub publisher: Option<String>,
326    pub scope: Option<String>,
327    pub installer_category: Option<String>,
328    pub install_location: Option<String>,
329    pub package_family_names: Vec<String>,
330    pub product_codes: Vec<String>,
331    pub upgrade_codes: Vec<String>,
332}
333
334#[derive(Debug, Clone, serde::Serialize)]
335#[serde(rename_all = "PascalCase")]
336pub struct ListResponse {
337    pub matches: Vec<ListMatch>,
338    pub warnings: Vec<String>,
339    pub truncated: bool,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
343#[serde(rename_all = "PascalCase")]
344pub struct VersionKey {
345    pub version: String,
346    pub channel: String,
347}
348
349#[derive(Debug, Clone, serde::Serialize)]
350pub struct Manifest {
351    pub id: String,
352    pub name: String,
353    pub version: String,
354    pub channel: String,
355    pub publisher: Option<String>,
356    pub description: Option<String>,
357    pub moniker: Option<String>,
358    pub package_url: Option<String>,
359    pub publisher_url: Option<String>,
360    pub publisher_support_url: Option<String>,
361    pub license: Option<String>,
362    pub license_url: Option<String>,
363    pub privacy_url: Option<String>,
364    pub author: Option<String>,
365    pub copyright: Option<String>,
366    pub copyright_url: Option<String>,
367    pub release_notes: Option<String>,
368    pub release_notes_url: Option<String>,
369    pub tags: Vec<String>,
370    pub agreements: Vec<PackageAgreement>,
371    pub package_dependencies: Vec<String>,
372    pub documentation: Vec<Documentation>,
373    pub icons: Vec<PackageIcon>,
374    pub installers: Vec<Installer>,
375    // `RequireExplicitUpgrade: true` opts a package out of bulk
376    // `pinget upgrade` output (winget parity). Users can still upgrade by
377    // explicit `id`. Set at top-level or per-installer; treated as true
378    // when any installer asserts it.
379    pub require_explicit_upgrade: bool,
380}
381
382impl Manifest {
383    pub fn icon(&self) -> Option<&PackageIcon> {
384        select_best_icon(&self.icons)
385    }
386
387    pub fn icon_url(&self) -> Option<&str> {
388        self.icon().and_then(|icon| icon.icon_url.as_deref())
389    }
390}
391
392#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
393#[serde(rename_all = "PascalCase")]
394pub struct PackageIcon {
395    pub icon_url: Option<String>,
396    pub icon_file_type: Option<String>,
397    pub icon_resolution: Option<String>,
398    pub icon_theme: Option<String>,
399    pub icon_sha256: Option<String>,
400}
401
402impl PackageIcon {
403    fn is_empty(&self) -> bool {
404        self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty())
405            && self
406                .icon_file_type
407                .as_deref()
408                .is_none_or(|value| value.trim().is_empty())
409            && self
410                .icon_resolution
411                .as_deref()
412                .is_none_or(|value| value.trim().is_empty())
413            && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty())
414            && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty())
415    }
416}
417
418fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> {
419    icons
420        .iter()
421        .enumerate()
422        .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty()))
423        .max_by(|(left_index, left), (right_index, right)| {
424            icon_theme_score(left.icon_theme.as_deref())
425                .cmp(&icon_theme_score(right.icon_theme.as_deref()))
426                .then_with(|| {
427                    icon_resolution_score(left.icon_resolution.as_deref())
428                        .cmp(&icon_resolution_score(right.icon_resolution.as_deref()))
429                })
430                .then_with(|| {
431                    icon_file_type_score(left.icon_file_type.as_deref())
432                        .cmp(&icon_file_type_score(right.icon_file_type.as_deref()))
433                })
434                .then_with(|| right_index.cmp(left_index))
435        })
436        .map(|(_, icon)| icon)
437}
438
439fn icon_theme_score(theme: Option<&str>) -> usize {
440    match theme {
441        None => 1,
442        Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1,
443        Some(_) => 0,
444    }
445}
446
447fn icon_file_type_score(file_type: Option<&str>) -> usize {
448    match file_type {
449        Some(value) if value.eq_ignore_ascii_case("ico") => 2,
450        Some(value) if value.eq_ignore_ascii_case("png") => 1,
451        _ => 0,
452    }
453}
454
455fn icon_resolution_score(resolution: Option<&str>) -> usize {
456    let Some(resolution) = resolution else {
457        return 0;
458    };
459    let resolution = resolution.trim();
460    let Some(separator) = resolution.find(['x', 'X']) else {
461        return 0;
462    };
463    let (width, height) = resolution.split_at(separator);
464    let height = &height[1..];
465    let (Ok(width), Ok(height)) = (width.parse::<usize>(), height.parse::<usize>()) else {
466        return 0;
467    };
468    if width == height { width } else { 0 }
469}
470
471#[derive(Debug, Clone, serde::Serialize)]
472pub struct Documentation {
473    pub label: Option<String>,
474    pub url: String,
475}
476
477#[derive(Debug, Clone, serde::Serialize)]
478pub struct PackageAgreement {
479    pub label: Option<String>,
480    pub text: Option<String>,
481    pub url: Option<String>,
482}
483
484#[derive(Debug, Clone, Default, serde::Serialize)]
485pub struct Installer {
486    pub architecture: Option<String>,
487    pub installer_type: Option<String>,
488    pub nested_installer_type: Option<String>,
489    pub nested_installer_files: Vec<NestedInstallerFile>,
490    pub url: Option<String>,
491    pub sha256: Option<String>,
492    pub product_code: Option<String>,
493    pub locale: Option<String>,
494    pub scope: Option<String>,
495    pub release_date: Option<String>,
496    pub package_family_name: Option<String>,
497    pub upgrade_code: Option<String>,
498    pub platforms: Vec<String>,
499    pub minimum_os_version: Option<String>,
500    pub switches: InstallerSwitches,
501    pub commands: Vec<String>,
502    pub package_dependencies: Vec<String>,
503    pub require_explicit_upgrade: bool,
504}
505
506#[derive(Debug, Clone, Default, serde::Serialize)]
507pub struct NestedInstallerFile {
508    /// Path to the executable within the extracted archive.
509    pub relative_file_path: String,
510    /// Optional alias winget exposes for portable invocation. Pinget records it
511    /// for parity and uses it during portable installation to create a
512    /// `Links\\<alias>.exe` symlink; `Links\\` may also be added to the user PATH.
513    pub portable_command_alias: Option<String>,
514}
515
516#[derive(Debug, Clone, Default, serde::Serialize)]
517pub struct InstallerSwitches {
518    pub silent: Option<String>,
519    pub silent_with_progress: Option<String>,
520    pub interactive: Option<String>,
521    pub custom: Option<String>,
522    pub log: Option<String>,
523    pub install_location: Option<String>,
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum InstallerMode {
528    Interactive,
529    SilentWithProgress,
530    Silent,
531}
532
533#[derive(Debug, Clone)]
534pub struct InstallRequest {
535    pub query: PackageQuery,
536    pub manifest_path: Option<PathBuf>,
537    pub mode: InstallerMode,
538    pub log_path: Option<PathBuf>,
539    pub custom: Option<String>,
540    pub override_args: Option<String>,
541    pub install_location: Option<String>,
542    pub skip_dependencies: bool,
543    pub dependencies_only: bool,
544    pub accept_package_agreements: bool,
545    pub force: bool,
546    pub rename: Option<String>,
547    pub uninstall_previous: bool,
548    pub ignore_security_hash: bool,
549    pub dependency_source: Option<String>,
550    pub no_upgrade: bool,
551}
552
553impl InstallRequest {
554    pub fn new(query: PackageQuery) -> Self {
555        Self {
556            query,
557            manifest_path: None,
558            mode: InstallerMode::SilentWithProgress,
559            log_path: None,
560            custom: None,
561            override_args: None,
562            install_location: None,
563            skip_dependencies: false,
564            dependencies_only: false,
565            accept_package_agreements: false,
566            force: false,
567            rename: None,
568            uninstall_previous: false,
569            ignore_security_hash: false,
570            dependency_source: None,
571            no_upgrade: false,
572        }
573    }
574}
575
576#[derive(Debug, Clone)]
577pub struct UninstallRequest {
578    pub query: PackageQuery,
579    pub manifest_path: Option<PathBuf>,
580    pub product_code: Option<String>,
581    pub mode: InstallerMode,
582    pub all_versions: bool,
583    pub force: bool,
584    pub purge: bool,
585    pub preserve: bool,
586    pub log_path: Option<PathBuf>,
587}
588
589impl UninstallRequest {
590    pub fn new(query: PackageQuery) -> Self {
591        Self {
592            query,
593            manifest_path: None,
594            product_code: None,
595            mode: InstallerMode::SilentWithProgress,
596            all_versions: false,
597            force: false,
598            purge: false,
599            preserve: false,
600            log_path: None,
601        }
602    }
603}
604
605#[derive(Debug, Clone)]
606pub struct RepairRequest {
607    pub query: PackageQuery,
608    pub manifest_path: Option<PathBuf>,
609    pub product_code: Option<String>,
610    pub mode: InstallerMode,
611    pub log_path: Option<PathBuf>,
612    pub accept_package_agreements: bool,
613    pub force: bool,
614    pub ignore_security_hash: bool,
615}
616
617impl RepairRequest {
618    pub fn new(query: PackageQuery) -> Self {
619        Self {
620            query,
621            manifest_path: None,
622            product_code: None,
623            mode: InstallerMode::SilentWithProgress,
624            log_path: None,
625            accept_package_agreements: false,
626            force: false,
627            ignore_security_hash: false,
628        }
629    }
630}
631
632#[derive(Debug, Clone, serde::Serialize)]
633pub struct ShowResult {
634    pub package: SearchMatch,
635    pub manifest: Manifest,
636    pub selected_installer: Option<Installer>,
637    pub cached_files: Vec<PathBuf>,
638    pub warnings: Vec<String>,
639    #[serde(skip_serializing)]
640    manifest_documents: JsonValue,
641}
642
643impl ShowResult {
644    pub fn structured_document(&self) -> JsonValue {
645        add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest)
646    }
647}
648
649/// Result of a `download` operation. In addition to the resolved installer
650/// file on disk, pinget mirrors `winget download` by writing a flattened YAML
651/// manifest next to the installer so users get a self-contained snapshot of
652/// the package version they downloaded.
653#[derive(Debug, Clone, serde::Serialize)]
654pub struct DownloadOutput {
655    pub manifest: Manifest,
656    pub installer_path: PathBuf,
657    pub manifest_path: PathBuf,
658}
659
660/// Internal result of fetching only the installer file. Used by the install
661/// flow which downloads into a temp dir and intentionally skips the manifest
662/// YAML side effect.
663struct FetchedInstaller {
664    manifest: Manifest,
665    manifest_documents: JsonValue,
666    installer: Installer,
667    installer_path: PathBuf,
668}
669
670#[derive(Debug, Clone, serde::Serialize)]
671#[serde(rename_all = "PascalCase")]
672pub struct VersionsResult {
673    pub package: SearchMatch,
674    pub versions: Vec<VersionKey>,
675    pub warnings: Vec<String>,
676}
677
678#[derive(Debug, Clone, serde::Serialize)]
679#[serde(rename_all = "PascalCase")]
680pub struct CacheWarmResult {
681    pub package: SearchMatch,
682    pub cached_files: Vec<PathBuf>,
683    pub warnings: Vec<String>,
684}
685
686#[derive(Debug, Clone, serde::Serialize)]
687#[serde(rename_all = "PascalCase")]
688pub struct SourceUpdateResult {
689    pub name: String,
690    pub kind: SourceKind,
691    pub detail: String,
692}
693
694#[derive(Debug, Clone, serde::Serialize)]
695#[serde(rename_all = "PascalCase")]
696pub struct PinRecord {
697    pub package_id: String,
698    pub version: String,
699    pub source_id: String,
700    pub pin_type: PinType,
701}
702
703#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
704pub enum PinType {
705    Pinning,
706    Blocking,
707    Gating,
708}
709
710impl Display for PinType {
711    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
712        match self {
713            PinType::Pinning => f.write_str("Pinning"),
714            PinType::Blocking => f.write_str("Blocking"),
715            PinType::Gating => f.write_str("Gating"),
716        }
717    }
718}
719
720/// Result of an install/uninstall/upgrade operation
721#[derive(Debug, Clone, serde::Serialize)]
722pub struct InstallResult {
723    pub package_id: String,
724    pub version: String,
725    pub installer_path: PathBuf,
726    pub installer_type: String,
727    pub exit_code: i32,
728    pub success: bool,
729    pub no_op: bool,
730    pub warnings: Vec<String>,
731}
732
733#[derive(Debug, Clone)]
734pub enum InstallProgress {
735    ResolvingManifest,
736    ResolvedManifest { package_id: String, version: String },
737    CheckingInstalled,
738    InstallingDependencies,
739    SelectingInstaller,
740    DownloadingInstaller { url: String, path: PathBuf },
741    VerifyingInstaller { path: PathBuf },
742    StartingInstaller { installer_type: String, path: PathBuf },
743    WaitingForInstaller { installer_type: String },
744    InstallerFinished { exit_code: i32 },
745}
746
747impl InstallerSwitches {
748    fn with_fallback(&self, fallback: &Self) -> Self {
749        Self {
750            silent: self.silent.clone().or_else(|| fallback.silent.clone()),
751            silent_with_progress: self
752                .silent_with_progress
753                .clone()
754                .or_else(|| fallback.silent_with_progress.clone()),
755            interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
756            custom: self.custom.clone().or_else(|| fallback.custom.clone()),
757            log: self.log.clone().or_else(|| fallback.log.clone()),
758            install_location: self
759                .install_location
760                .clone()
761                .or_else(|| fallback.install_location.clone()),
762        }
763    }
764}
765
766fn installed_package_discovery_supported() -> bool {
767    cfg!(windows)
768}
769
770fn package_actions_supported() -> bool {
771    cfg!(windows)
772}
773
774fn unsupported_action_result(
775    package_id: impl Into<String>,
776    version: impl Into<String>,
777    installer_type: impl Into<String>,
778    warning: &str,
779) -> InstallResult {
780    InstallResult {
781        package_id: package_id.into(),
782        version: version.into(),
783        installer_path: PathBuf::new(),
784        installer_type: installer_type.into(),
785        exit_code: 0,
786        success: true,
787        no_op: true,
788        warnings: vec![warning.to_owned()],
789    }
790}
791
792#[derive(Debug, Clone)]
793struct InstalledPackage {
794    name: String,
795    local_id: String,
796    installed_version: String,
797    publisher: Option<String>,
798    scope: Option<String>,
799    installer_category: Option<String>,
800    install_location: Option<String>,
801    winget_package_identifier: Option<String>,
802    winget_source_identifier: Option<String>,
803    package_family_names: Vec<String>,
804    product_codes: Vec<String>,
805    upgrade_codes: Vec<String>,
806    correlated: Option<SearchMatch>,
807    // True when `installed_version` was rewritten to a catalog Version via
808    // versionData.mszyml's aMiV/aMaV range. Such versions compare meaningfully
809    // against the catalog's Version; raw ARP DisplayVersions usually don't.
810    // Used by dedupe so that, when the same id has both a canonical and a raw
811    // install row, we prefer the canonical (e.g. `Microsoft.DotNet.SDK.10`
812    // 10.0.108 over the VS-installed 40.10.18029 that doesn't map).
813    installed_version_canonical: bool,
814    // True when the correlated catalog package's latest version sets
815    // `RequireExplicitUpgrade: true`. winget hides those rows from bulk
816    // `upgrade`; we mirror that. Users can still upgrade by explicit id.
817    correlated_requires_explicit_upgrade: bool,
818    // True when the correlated catalog package's latest version has no
819    // installer for an architecture the user can actually run. winget
820    // hides those from `upgrade`; we mirror that.
821    correlated_lacks_compatible_installer: bool,
822}
823
824#[derive(Debug, Clone)]
825struct LocatedMatch {
826    display: SearchMatch,
827    source_index: usize,
828    locator: MatchLocator,
829}
830
831#[derive(Debug)]
832struct SearchSourceMatches {
833    matches: Vec<LocatedMatch>,
834    truncated: bool,
835}
836
837#[derive(Debug, Clone)]
838struct SourceSearchFailure {
839    source_name: String,
840    message: String,
841}
842
843#[derive(Debug)]
844struct SearchLocatedResult {
845    matches: Vec<LocatedMatch>,
846    warnings: Vec<String>,
847    failures: Vec<SourceSearchFailure>,
848    truncated: bool,
849}
850
851fn single_match_from_located(
852    located: SearchLocatedResult,
853    report_first_source_failure: bool,
854) -> Result<(LocatedMatch, Vec<String>)> {
855    if located.matches.is_empty() {
856        if report_first_source_failure && let Some(failure) = located.failures.first() {
857            bail!("failed to search source '{}': {}", failure.source_name, failure.message);
858        }
859        bail!("no package matched the supplied query");
860    }
861
862    if located.matches.len() > 1 {
863        let choices = located
864            .matches
865            .iter()
866            .take(10)
867            .map(|item| {
868                format!(
869                    "{} [{}] ({})",
870                    item.display.name, item.display.id, item.display.source_name
871                )
872            })
873            .collect::<Vec<_>>()
874            .join(", ");
875        bail!("multiple packages matched: {choices}");
876    }
877
878    Ok((located.matches.into_iter().next().expect("one match"), located.warnings))
879}
880
881fn should_retry_show_across_system_winget_sources(
882    uses_system_winget_sources: bool,
883    query: &PackageQuery,
884    located: &SearchLocatedResult,
885) -> bool {
886    uses_system_winget_sources
887        && query
888            .source
889            .as_deref()
890            .is_some_and(|source| source.eq_ignore_ascii_case("winget"))
891        && located.matches.is_empty()
892        && located.failures.is_empty()
893}
894
895#[derive(Debug, Clone)]
896enum MatchLocator {
897    PreIndexedV1 {
898        package_rowid: i64,
899    },
900    PreIndexedV2 {
901        package_rowid: i64,
902        package_hash: String,
903    },
904    Rest {
905        package_id: String,
906        versions: Vec<VersionKey>,
907    },
908}
909
910#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911enum SearchSemantics {
912    Many,
913    Single,
914}
915
916#[derive(Debug, Clone, Serialize, Deserialize)]
917struct RestInfoCache {
918    expires_at: DateTime<Utc>,
919    value: RestInformation,
920}
921
922#[derive(Debug, Clone, Default, Serialize, Deserialize)]
923struct RestInformation {
924    #[serde(rename = "SourceIdentifier")]
925    source_identifier: String,
926    #[serde(rename = "ServerSupportedVersions", default)]
927    server_supported_versions: Vec<String>,
928    #[serde(rename = "RequiredPackageMatchFields", default)]
929    required_package_match_fields: Vec<String>,
930    #[serde(rename = "UnsupportedPackageMatchFields", default)]
931    unsupported_package_match_fields: Vec<String>,
932    #[serde(rename = "RequiredQueryParameters", default)]
933    required_query_parameters: Vec<String>,
934    #[serde(rename = "UnsupportedQueryParameters", default)]
935    unsupported_query_parameters: Vec<String>,
936}
937
938#[derive(Debug, Clone, Deserialize)]
939struct PackageVersionDataDocument {
940    #[serde(rename = "vD", default)]
941    versions: Vec<PackageVersionDataEntry>,
942}
943
944#[derive(Debug, Clone, Deserialize)]
945struct PackageVersionDataEntry {
946    #[serde(rename = "v")]
947    version: String,
948    #[serde(rename = "rP")]
949    manifest_relative_path: String,
950    #[serde(rename = "s256H")]
951    manifest_hash: String,
952    // ARP version range covered by this catalog version. WinGet writes these
953    // into versionData.mszyml so an installed ARP DisplayVersion (which may be
954    // an MSI build number with no relation to the catalog Version) can be
955    // mapped back to the catalog Version it corresponds to.
956    #[serde(rename = "aMiV", default)]
957    arp_min_version: Option<String>,
958    #[serde(rename = "aMaV", default)]
959    arp_max_version: Option<String>,
960}
961
962pub struct Repository {
963    app_root: PathBuf,
964    user_agent: String,
965    client: Client,
966    store: SourceStore,
967    source_mode: EffectiveSourceMode,
968    install_progress: Option<fn(&InstallProgress)>,
969    pre_indexed_source_auto_update_interval: Option<Duration>,
970    preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
971}
972
973#[derive(Debug, Clone)]
974struct PreindexedRefreshAttempt {
975    attempted_at: DateTime<Utc>,
976    succeeded: bool,
977}
978
979#[derive(Debug, Clone, Default)]
980struct PreindexedRefreshOutcome {
981    attempted: bool,
982    reused_recent_attempt: bool,
983    error: Option<String>,
984}
985
986struct PreindexedOpenConnection {
987    connection: Connection,
988    refresh: PreindexedRefreshOutcome,
989}
990
991#[derive(Debug)]
992struct MissingPackageVersionError {
993    package_id: String,
994    source_name: String,
995    requested_version: String,
996    requested_channel: Option<String>,
997}
998
999impl Display for MissingPackageVersionError {
1000    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1001        let channel = self
1002            .requested_channel
1003            .as_deref()
1004            .filter(|value| !value.is_empty())
1005            .map(|value| format!(" with channel '{value}'"))
1006            .unwrap_or_default();
1007        write!(
1008            f,
1009            "Version '{}'{} was not found for package '{}' in source '{}'.",
1010            self.requested_version, channel, self.package_id, self.source_name
1011        )
1012    }
1013}
1014
1015impl std::error::Error for MissingPackageVersionError {}
1016
1017fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
1018    static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
1019    LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
1020}
1021
1022impl Repository {
1023    /// Opens the repository with the default per-user CLI storage layout.
1024    pub fn open() -> Result<Self> {
1025        Self::open_with_options(RepositoryOptions::for_current_user()?)
1026    }
1027
1028    /// Opens the repository with explicit hosting options for library consumers.
1029    pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
1030        ensure_app_dirs(&options.app_root)?;
1031        let (source_mode, store) = load_effective_source_store(&options.app_root, options.source_mode)?;
1032        let client = Client::builder()
1033            .user_agent(&options.user_agent)
1034            .timeout(StdDuration::from_secs(30 * 60))
1035            .build()
1036            .context("failed to build HTTP client")?;
1037        Ok(Self {
1038            app_root: options.app_root,
1039            user_agent: options.user_agent,
1040            client,
1041            store,
1042            source_mode,
1043            install_progress: options.install_progress,
1044            pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
1045            preindexed_refresh_attempts: HashMap::new(),
1046        })
1047    }
1048
1049    fn uses_system_winget_sources(&self) -> bool {
1050        self.source_mode != EffectiveSourceMode::Private
1051    }
1052
1053    fn mirrors_system_winget_sources(&self) -> bool {
1054        self.source_mode == EffectiveSourceMode::SystemWingetMirror
1055    }
1056
1057    fn emit_install_progress(&self, progress: InstallProgress) {
1058        if let Some(callback) = self.install_progress {
1059            callback(&progress);
1060        }
1061    }
1062
1063    pub fn app_root(&self) -> &Path {
1064        &self.app_root
1065    }
1066
1067    pub fn list_sources(&self) -> Vec<SourceRecord> {
1068        self.store.sources.clone()
1069    }
1070
1071    pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
1072        self.add_source_with_metadata(name, arg, kind, None, false, 0)
1073    }
1074
1075    pub fn add_source_with_metadata(
1076        &mut self,
1077        name: &str,
1078        arg: &str,
1079        kind: SourceKind,
1080        trust_level: Option<&str>,
1081        explicit: bool,
1082        priority: i32,
1083    ) -> Result<()> {
1084        if self.uses_system_winget_sources() {
1085            system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
1086            self.refresh_system_winget_sources()?;
1087            return Ok(());
1088        }
1089
1090        if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
1091            bail!("A source with name '{name}' already exists.");
1092        }
1093        if self.store.sources.iter().any(|s| s.arg == arg) {
1094            bail!("A source with argument '{arg}' already exists.");
1095        }
1096        let identifier = name.to_owned();
1097        self.store.sources.push(SourceRecord {
1098            name: name.to_owned(),
1099            kind,
1100            arg: arg.to_owned(),
1101            identifier,
1102            trust_level: normalize_source_trust_level(trust_level)?,
1103            explicit,
1104            priority,
1105            last_update: None,
1106            source_version: None,
1107            etag: None,
1108            last_modified: None,
1109        });
1110        if self.source_mode != EffectiveSourceMode::SystemWingetDirect {
1111            self.save_store()?;
1112        }
1113        Ok(())
1114    }
1115
1116    pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
1117        if self.uses_system_winget_sources() {
1118            bail!("Editing system WinGet sources is not supported by winget source commands.");
1119        }
1120
1121        let source = self
1122            .store
1123            .sources
1124            .iter_mut()
1125            .find(|source| source.name.eq_ignore_ascii_case(name))
1126            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1127
1128        if let Some(explicit) = explicit {
1129            source.explicit = explicit;
1130        }
1131
1132        if trust_level.is_some() {
1133            source.trust_level = normalize_source_trust_level(trust_level)?;
1134        }
1135
1136        self.save_store()?;
1137        Ok(())
1138    }
1139
1140    pub fn remove_source(&mut self, name: &str) -> Result<()> {
1141        if self.uses_system_winget_sources() {
1142            system_winget_remove_source(name)?;
1143            self.refresh_system_winget_sources()?;
1144            return Ok(());
1145        }
1146
1147        let idx = self
1148            .store
1149            .sources
1150            .iter()
1151            .position(|s| s.name.eq_ignore_ascii_case(name))
1152            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1153        let source = self.store.sources.remove(idx);
1154        // Clean up cached state for this source
1155        let state_dir = self.source_state_dir(&source);
1156        if state_dir.exists() {
1157            let _ = fs::remove_dir_all(&state_dir);
1158        }
1159        self.save_store()?;
1160        Ok(())
1161    }
1162
1163    pub fn reset_source(&mut self, name: &str) -> Result<()> {
1164        if self.uses_system_winget_sources() {
1165            system_winget_reset_source(name)?;
1166            self.refresh_system_winget_sources()?;
1167            return Ok(());
1168        }
1169
1170        let idx = self
1171            .store
1172            .sources
1173            .iter()
1174            .position(|source| source.name.eq_ignore_ascii_case(name))
1175            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1176
1177        let source = self.store.sources[idx].clone();
1178        let state_dir = self.source_state_dir(&source);
1179        if state_dir.exists() {
1180            let _ = fs::remove_dir_all(&state_dir);
1181        }
1182
1183        if let Some(default_source) = SourceStore::default()
1184            .sources
1185            .into_iter()
1186            .find(|source| source.name.eq_ignore_ascii_case(name))
1187        {
1188            self.store.sources[idx] = default_source;
1189        } else {
1190            self.store.sources[idx].last_update = None;
1191            self.store.sources[idx].source_version = None;
1192            self.store.sources[idx].etag = None;
1193            self.store.sources[idx].last_modified = None;
1194        }
1195
1196        self.save_store()?;
1197        Ok(())
1198    }
1199
1200    pub fn reset_sources(&mut self) -> Result<()> {
1201        if self.uses_system_winget_sources() {
1202            system_winget_reset_sources()?;
1203            self.refresh_system_winget_sources()?;
1204            return Ok(());
1205        }
1206
1207        // Remove cached state for all current sources
1208        for source in &self.store.sources {
1209            let state_dir = self.source_state_dir(source);
1210            if state_dir.exists() {
1211                let _ = fs::remove_dir_all(&state_dir);
1212            }
1213        }
1214        self.store = SourceStore::default();
1215        self.save_store()?;
1216        Ok(())
1217    }
1218
1219    pub fn supported_admin_settings() -> &'static [&'static str] {
1220        SUPPORTED_ADMIN_SETTINGS
1221    }
1222
1223    pub fn get_user_settings(&self) -> Result<JsonValue> {
1224        Ok(JsonValue::Object(load_json_object(&user_settings_path(
1225            &self.app_root,
1226        ))?))
1227    }
1228
1229    pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
1230        let update = match user_settings {
1231            JsonValue::Object(object) => object.clone(),
1232            _ => bail!("user settings must be a JSON object"),
1233        };
1234
1235        let effective = if merge {
1236            merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
1237        } else {
1238            update
1239        };
1240        save_json_object(user_settings_path(&self.app_root), &effective)?;
1241        Ok(JsonValue::Object(effective))
1242    }
1243
1244    pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
1245        let current = self.get_user_settings()?;
1246        Ok(if ignore_not_set {
1247            json_contains(&current, expected)
1248        } else {
1249            current == *expected
1250        })
1251    }
1252
1253    pub fn get_admin_settings(&self) -> Result<JsonValue> {
1254        let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
1255        for name in SUPPORTED_ADMIN_SETTINGS {
1256            settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
1257        }
1258
1259        Ok(JsonValue::Object(settings))
1260    }
1261
1262    pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1263        let normalized = normalize_admin_setting_name(name)?;
1264        let mut settings = match self.get_admin_settings()? {
1265            JsonValue::Object(settings) => settings,
1266            _ => JsonMap::new(),
1267        };
1268        settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1269        save_json_object(admin_settings_path(&self.app_root), &settings)
1270    }
1271
1272    pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1273        if !reset_all && name.is_none() {
1274            bail!("resetting admin settings requires a setting name or reset-all");
1275        }
1276
1277        let mut settings = match self.get_admin_settings()? {
1278            JsonValue::Object(settings) => settings,
1279            _ => JsonMap::new(),
1280        };
1281
1282        if reset_all {
1283            for setting_name in SUPPORTED_ADMIN_SETTINGS {
1284                settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1285            }
1286        } else {
1287            settings.insert(
1288                normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1289                JsonValue::Bool(false),
1290            );
1291        }
1292
1293        save_json_object(admin_settings_path(&self.app_root), &settings)
1294    }
1295
1296    pub fn ensure_settings_files(&self) -> Result<()> {
1297        let user_settings = user_settings_path(&self.app_root);
1298        if !user_settings.exists() {
1299            save_json_object(user_settings, &JsonMap::new())?;
1300        }
1301
1302        let admin_settings = admin_settings_path(&self.app_root);
1303        if !admin_settings.exists() {
1304            let mut values = JsonMap::new();
1305            for name in SUPPORTED_ADMIN_SETTINGS {
1306                values.insert((*name).to_owned(), JsonValue::Bool(false));
1307            }
1308            save_json_object(admin_settings, &values)?;
1309        }
1310
1311        Ok(())
1312    }
1313
1314    pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1315        if self.source_mode == EffectiveSourceMode::SystemWingetDirect {
1316            return self.update_system_winget_sources(source_name);
1317        }
1318
1319        if self.mirrors_system_winget_sources() {
1320            self.refresh_system_winget_sources()?;
1321        }
1322
1323        let indexes = self.resolve_source_indexes(source_name)?;
1324        let mut results = Vec::new();
1325
1326        for index in indexes {
1327            let detail = match self.store.sources[index].kind {
1328                SourceKind::PreIndexed => self.update_preindexed(index)?,
1329                SourceKind::Rest => self.update_rest(index)?,
1330            };
1331
1332            results.push(SourceUpdateResult {
1333                name: self.store.sources[index].name.clone(),
1334                kind: self.store.sources[index].kind,
1335                detail,
1336            });
1337        }
1338
1339        self.save_store()?;
1340        Ok(results)
1341    }
1342
1343    pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1344        let located = self.search_located(query, SearchSemantics::Many)?;
1345        Ok(SearchResponse {
1346            matches: located.matches.into_iter().map(|item| item.display).collect(),
1347            warnings: located.warnings,
1348            truncated: located.truncated,
1349        })
1350    }
1351
1352    pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1353        let located = self.search_located(query, SearchSemantics::Many)?;
1354        let mut structured_documents = Vec::with_capacity(located.matches.len());
1355        for located in located.matches {
1356            let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1357            structured_documents.push(manifest_documents);
1358        }
1359
1360        Ok(collapse_structured_documents(&structured_documents))
1361    }
1362
1363    pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1364        if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1365            bail!("--include-unknown and --include-pinned require --upgrade-available");
1366        }
1367        if query.source.is_some()
1368            && !query.upgrade_only
1369            && query.query.is_none()
1370            && query.id.is_none()
1371            && query.name.is_none()
1372            && query.moniker.is_none()
1373            && query.tag.is_none()
1374            && query.command.is_none()
1375        {
1376            bail!("list --source currently requires a query or explicit filter");
1377        }
1378
1379        self.refresh_system_winget_sources()?;
1380
1381        let has_filter = list_query_needs_available_lookup(query);
1382        let needs_available = has_filter || query.upgrade_only;
1383
1384        let mut warnings = Vec::new();
1385        if !installed_package_discovery_supported() {
1386            warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1387        }
1388        let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1389
1390        // Plain `list` should still correlate installed packages back to their
1391        // catalog identities so callers see canonical package ids/source names.
1392        // Available-version lookups and loose name-only enrichment remain gated
1393        // by `needs_available` below.
1394        warnings.extend(self.correlate_installed_via_installed_db(&mut installed, query.source.as_deref())?);
1395        warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1396        warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1397
1398        if needs_available && has_filter {
1399            // Filtered lookup: search sources with the user's query for any
1400            // installed package not already resolved by identity.
1401            let available_query = package_query_from_list_query(query);
1402            let located = self.search_located(&available_query, SearchSemantics::Many)?;
1403            warnings.extend(located.warnings);
1404            let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1405            for package in &mut installed {
1406                if package.correlated.is_some() {
1407                    continue;
1408                }
1409                package.correlated =
1410                    correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1411            }
1412        } else if needs_available && !query.upgrade_only {
1413            // Plain unfiltered list: preserve legacy best-effort catalog
1414            // display/source enrichment. Bulk upgrade avoids this loose
1415            // name-only pass because winget only upgrades packages correlated
1416            // through stronger identity/normalization paths.
1417            warnings.extend(self.correlate_all_installed(&mut installed)?);
1418        }
1419
1420        if needs_available {
1421            // Enrich rows that correlated via the name-based fallback. Those
1422            // rows skipped the v2-index aMiV/aMaV pass that runs inside
1423            // `correlate_installed_via_index`, so they still carry the raw
1424            // ARP DisplayVersion (e.g. `JetBrains Rider 2025.3.0.1` showing
1425            // build `253.28294.112` instead of the marketing `2025.3.0.1`).
1426            // Also resolves MSIX placeholder names like
1427            // `ms-resource:appDisplayName` to the catalog's display name —
1428            // winget pulls these through Windows.Management.Deployment; the
1429            // catalog name is the parity-faithful substitute pinget has
1430            // available without taking a WinRT dependency.
1431            warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1432        }
1433
1434        // For `upgrade`, collapse multiple installed entries that map to the
1435        // same catalog package id (different ARP rows for side-by-side .NET
1436        // SDKs, several runtime versions of WindowsAppRuntime, the MSIX shim
1437        // alongside an ARP install of Edge, etc.). Keep the entry with the
1438        // highest installed_version so the upgrade comparison runs against
1439        // the user's newest install — matches winget's one-row-per-id output.
1440        if query.upgrade_only {
1441            installed = dedupe_correlated_for_upgrade(installed);
1442        }
1443
1444        let mut matches = installed
1445            .into_iter()
1446            .filter(|package| {
1447                list_package_matches(package, query)
1448                    && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1449            })
1450            .collect::<Vec<_>>();
1451
1452        matches.sort_by(|left, right| {
1453            list_sort_weight(left)
1454                .cmp(&list_sort_weight(right))
1455                .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1456                .then_with(|| left.local_id.cmp(&right.local_id))
1457        });
1458
1459        let pins = if query.upgrade_only {
1460            self.list_pins(query.source.as_deref())?
1461        } else {
1462            Vec::new()
1463        };
1464        let mut list_matches = matches
1465            .into_iter()
1466            .map(|package| list_match_from_installed(package, &self.store.sources))
1467            .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1468            .collect::<Vec<_>>();
1469        if !query.upgrade_only {
1470            list_matches = suppress_duplicate_available_versions(list_matches);
1471        }
1472        let truncated = if let Some(limit) = query.count {
1473            let was_truncated = list_matches.len() > limit;
1474            list_matches.truncate(limit);
1475            was_truncated
1476        } else {
1477            false
1478        };
1479
1480        Ok(ListResponse {
1481            matches: list_matches,
1482            warnings,
1483            truncated,
1484        })
1485    }
1486
1487    /// For unfiltered upgrade/list, search the entire available index and correlate
1488    /// against all installed packages. Skips packages already correlated via the
1489    /// v2 identity tables.
1490    fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1491        if installed.iter().all(|p| p.correlated.is_some()) {
1492            return Ok(Vec::new());
1493        }
1494        let all_query = PackageQuery {
1495            query: None,
1496            id: None,
1497            name: None,
1498            moniker: None,
1499            tag: None,
1500            command: None,
1501            source: None,
1502            count: Some(100_000), // fetch the entire index
1503            exact: false,
1504            version: None,
1505            channel: None,
1506            locale: None,
1507            installer_type: None,
1508            installer_architecture: None,
1509            platform: None,
1510            os_version: None,
1511            install_scope: None,
1512        };
1513        let located = self.search_located(&all_query, SearchSemantics::Many)?;
1514        let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1515
1516        for package in installed.iter_mut() {
1517            if package.correlated.is_some() {
1518                continue;
1519            }
1520            package.correlated = correlate_installed_package(package, &candidates, true);
1521        }
1522
1523        Ok(located.warnings)
1524    }
1525
1526    /// Uses winget's per-source installed tracking DB when available. This is
1527    /// an exact identity signal recorded by winget itself, and it preserves
1528    /// source order for sources that do not expose a preindexed identity table
1529    /// such as REST sources.
1530    fn correlate_installed_via_installed_db(
1531        &mut self,
1532        installed: &mut [InstalledPackage],
1533        requested_source: Option<&str>,
1534    ) -> Result<Vec<String>> {
1535        let source_indices = self
1536            .store
1537            .sources
1538            .iter()
1539            .enumerate()
1540            .filter(|(_, source)| {
1541                requested_source
1542                    .map(|name| source.name.eq_ignore_ascii_case(name))
1543                    .unwrap_or(true)
1544            })
1545            .map(|(index, _)| index)
1546            .collect::<Vec<_>>();
1547
1548        for source_index in source_indices {
1549            let source = self.source_clone(source_index);
1550            let installed_db_path = self.source_installed_db_path(&source);
1551            if !installed_db_path.exists() {
1552                continue;
1553            }
1554
1555            let connection = Self::open_sqlite_connection(installed_db_path)?;
1556            if !installed_db_identity_tables_present(&connection)? {
1557                continue;
1558            }
1559
1560            for package in installed.iter_mut() {
1561                if package.correlated.is_some() {
1562                    continue;
1563                }
1564                let Some((id, name, matched_by)) = lookup_installed_db_identity_match(&connection, package)? else {
1565                    continue;
1566                };
1567                let (id, name, moniker, version) = if source.kind == SourceKind::PreIndexed {
1568                    match self
1569                        .open_preindexed_connection(source_index)
1570                        .and_then(|index_connection| {
1571                            let Some(meta) = lookup_v2_metadata_by_id(&index_connection, &id)? else {
1572                                return Ok((id.clone(), name.clone(), None, None));
1573                            };
1574                            let (canonical_installed, anchored_latest) =
1575                                match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1576                                    Ok((entries, _)) => (
1577                                        map_arp_version_to_catalog(&entries, &package.installed_version)
1578                                            .or_else(|| {
1579                                                less_than_latest_arp_anchored_version(
1580                                                    &entries,
1581                                                    &package.installed_version,
1582                                                )
1583                                            })
1584                                            .or_else(|| {
1585                                                greater_than_latest_arp_anchored_version(
1586                                                    &entries,
1587                                                    &package.installed_version,
1588                                                    &meta.latest_version,
1589                                                )
1590                                            }),
1591                                        latest_arp_anchored_version(&entries),
1592                                    ),
1593                                    Err(_) => (None, None),
1594                                };
1595                            let version = match (&canonical_installed, anchored_latest) {
1596                                (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1597                                (Some(_), Some(anchored)) => Some(anchored),
1598                                (Some(_), None) => None,
1599                                _ => Some(meta.latest_version.clone()),
1600                            };
1601                            if let Some(version) = canonical_installed {
1602                                package.installed_version = version;
1603                                package.installed_version_canonical = true;
1604                            }
1605                            Ok((meta.id, meta.name, meta.moniker, version))
1606                        }) {
1607                        Ok(enriched) => enriched,
1608                        Err(_) => (id, name, None, None),
1609                    }
1610                } else {
1611                    (id, name, None, None)
1612                };
1613                package.correlated = Some(SearchMatch {
1614                    source_name: source.name.clone(),
1615                    source_kind: source.kind,
1616                    id,
1617                    name,
1618                    moniker,
1619                    version,
1620                    channel: None,
1621                    match_criteria: Some(matched_by.to_owned()),
1622                });
1623            }
1624        }
1625
1626        Ok(Vec::new())
1627    }
1628
1629    /// Correlates installed packages against the v2 pre-indexed catalog using
1630    /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's
1631    /// authoritative correlation path. Also rewrites `installed_version` to
1632    /// the catalog Version whose ARP range covers the ARP DisplayVersion, so
1633    /// packages whose ARP version is an MSI build number (e.g. .NET SDK,
1634    /// Python) compare against a meaningful catalog version. Packages without
1635    /// any of the three identifiers, or whose identifiers don't match a
1636    /// catalog entry, are left untouched for the name-based fallback.
1637    fn correlate_installed_via_index(
1638        &mut self,
1639        installed: &mut [InstalledPackage],
1640        requested_source: Option<&str>,
1641    ) -> Result<Vec<String>> {
1642        let source_indices: Vec<usize> = self
1643            .store
1644            .sources
1645            .iter()
1646            .enumerate()
1647            .filter(|(_, source)| {
1648                source.kind == SourceKind::PreIndexed
1649                    && match requested_source {
1650                        Some(name) => source.name.eq_ignore_ascii_case(name),
1651                        None => true,
1652                    }
1653            })
1654            .map(|(index, _)| index)
1655            .collect();
1656
1657        for source_index in source_indices {
1658            let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1659                let connection = match self.open_preindexed_connection(source_index) {
1660                    Ok(c) => c,
1661                    Err(_) => continue,
1662                };
1663                if !v2_identity_tables_present(&connection) {
1664                    continue;
1665                }
1666                let mut acc = Vec::new();
1667                for (idx, package) in installed.iter().enumerate() {
1668                    if package.correlated.is_some() {
1669                        continue;
1670                    }
1671                    let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1672                        continue;
1673                    };
1674                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1675                        continue;
1676                    };
1677                    acc.push((idx, meta, by));
1678                }
1679                acc
1680            };
1681
1682            let source = self.source_clone(source_index);
1683            for (idx, meta, by) in resolved {
1684                let (canonical_installed, anchored_latest) =
1685                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1686                        Ok((entries, _)) => (
1687                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1688                                .or_else(|| {
1689                                    less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1690                                })
1691                                .or_else(|| {
1692                                    greater_than_latest_arp_anchored_version(
1693                                        &entries,
1694                                        &installed[idx].installed_version,
1695                                        &meta.latest_version,
1696                                    )
1697                                }),
1698                            latest_arp_anchored_version(&entries),
1699                        ),
1700                        Err(_) => (None, None),
1701                    };
1702                // When the installed side was rebased to a catalog Version via
1703                // aMiV/aMaV, the available side must compare on the same scale:
1704                // prefer the latest ARP-anchored Version over `latest_version`,
1705                // which can be an internal/MSI build number (e.g. WinAppRuntime
1706                // 1.8's `8000.836.2153.0`) that would manufacture a phantom
1707                // upgrade against an installed canonical like `1.8.6`.
1708                let available_version = match (&canonical_installed, anchored_latest) {
1709                    (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1710                    (Some(_), Some(anchored)) => Some(anchored),
1711                    (Some(_), None) => None,
1712                    _ => Some(meta.latest_version),
1713                };
1714                let installed_pkg = &mut installed[idx];
1715                installed_pkg.correlated = Some(SearchMatch {
1716                    source_name: source.name.clone(),
1717                    source_kind: source.kind,
1718                    id: meta.id,
1719                    name: meta.name,
1720                    moniker: meta.moniker,
1721                    version: available_version,
1722                    channel: None,
1723                    match_criteria: Some(by.to_owned()),
1724                });
1725                if let Some(version) = canonical_installed {
1726                    installed_pkg.installed_version = version;
1727                    installed_pkg.installed_version_canonical = true;
1728                }
1729            }
1730        }
1731
1732        Ok(Vec::new())
1733    }
1734
1735    /// Mid-tier correlation that mirrors winget's ARP normalization path.
1736    /// For each installed package without a PFN/PC/UC identity match, the
1737    /// (DisplayName, Publisher) is run through the NameNormalizer and
1738    /// intersected against the v2 index's `norm_names2` and
1739    /// `norm_publishers2` tables. A single resulting `package_rowid`
1740    /// produces a correlation; ambiguous matches are skipped, matching
1741    /// winget's empty-Source behavior when normalization can't pick a
1742    /// winner.
1743    fn correlate_installed_by_normalized_identity(
1744        &mut self,
1745        installed: &mut [InstalledPackage],
1746        requested_source: Option<&str>,
1747    ) -> Result<Vec<String>> {
1748        let source_indices: Vec<usize> = self
1749            .store
1750            .sources
1751            .iter()
1752            .enumerate()
1753            .filter(|(_, source)| {
1754                source.kind == SourceKind::PreIndexed
1755                    && match requested_source {
1756                        Some(name) => source.name.eq_ignore_ascii_case(name),
1757                        None => true,
1758                    }
1759            })
1760            .map(|(index, _)| index)
1761            .collect();
1762
1763        for source_index in source_indices {
1764            let resolved: Vec<(usize, V2PackageMetadata)> = {
1765                let connection = match self.open_preindexed_connection(source_index) {
1766                    Ok(c) => c,
1767                    Err(_) => continue,
1768                };
1769                if !v2_normalized_identity_tables_present(&connection) {
1770                    continue;
1771                }
1772                let mut acc = Vec::new();
1773                for (idx, pkg) in installed.iter().enumerate() {
1774                    if pkg.correlated.is_some() {
1775                        continue;
1776                    }
1777                    // MSIX entries belong to the PFN-only path; skipping
1778                    // here mirrors `correlate_installed_package`'s
1779                    // hard-block on MSIX (their `ms-resource:` placeholder
1780                    // names normalize to garbage anyway).
1781                    if pkg.local_id.starts_with("MSIX\\") {
1782                        continue;
1783                    }
1784                    let Some(publisher) = pkg.publisher.as_deref() else {
1785                        continue;
1786                    };
1787                    let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1788                    if normalized_name.is_empty() {
1789                        continue;
1790                    }
1791                    let normalized_publisher = name_normalization::normalize_publisher(publisher);
1792                    if normalized_publisher.is_empty() {
1793                        continue;
1794                    }
1795                    let Some(rowid) =
1796                        lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1797                    else {
1798                        continue;
1799                    };
1800                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1801                        continue;
1802                    };
1803                    acc.push((idx, meta));
1804                }
1805                acc
1806            };
1807
1808            let source = self.source_clone(source_index);
1809            for (idx, meta) in resolved {
1810                let (canonical_installed, anchored_latest) =
1811                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1812                        Ok((entries, _)) => (
1813                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1814                                .or_else(|| {
1815                                    less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1816                                })
1817                                .or_else(|| {
1818                                    greater_than_latest_arp_anchored_version(
1819                                        &entries,
1820                                        &installed[idx].installed_version,
1821                                        &meta.latest_version,
1822                                    )
1823                                }),
1824                            latest_arp_anchored_version(&entries),
1825                        ),
1826                        Err(_) => (None, None),
1827                    };
1828                let available_version = match (&canonical_installed, anchored_latest) {
1829                    (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1830                    (Some(_), Some(anchored)) => Some(anchored),
1831                    (Some(_), None) => None,
1832                    _ => Some(meta.latest_version),
1833                };
1834                let installed_pkg = &mut installed[idx];
1835                installed_pkg.correlated = Some(SearchMatch {
1836                    source_name: source.name.clone(),
1837                    source_kind: source.kind,
1838                    id: meta.id,
1839                    name: meta.name,
1840                    moniker: meta.moniker,
1841                    version: available_version,
1842                    channel: None,
1843                    match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1844                });
1845                if let Some(version) = canonical_installed {
1846                    installed_pkg.installed_version = version;
1847                    installed_pkg.installed_version_canonical = true;
1848                }
1849            }
1850        }
1851
1852        Ok(Vec::new())
1853    }
1854
1855    /// Runs after both correlation paths to enrich already-correlated rows
1856    /// with information that the identity path applies inline but that
1857    /// name-based correlation skips:
1858    ///   • The MSIX-placeholder name fix — when an MSIX entry's installed
1859    ///     name is an unresolved Windows resource string (`ms-resource:...`)
1860    ///     winget renders the catalog's `PackageName` instead. We do the
1861    ///     same so display-name comparisons match.
1862    ///   • The aMiV/aMaV version remap — `JetBrains.Rider` has no
1863    ///     ProductCode/PFN/UpgradeCode so it correlates by name; without
1864    ///     this pass its installed version stays as the ARP build number
1865    ///     `253.28294.112` instead of the catalog Version `2025.3.0.1`.
1866    fn enrich_correlated_via_index(
1867        &mut self,
1868        installed: &mut [InstalledPackage],
1869        requested_source: Option<&str>,
1870    ) -> Result<Vec<String>> {
1871        // Cheap pass first — no SQL required.
1872        for package in installed.iter_mut() {
1873            apply_msix_resource_string_name_fix(package);
1874        }
1875
1876        // Expensive pass — load versionData for rows whose installed_version
1877        // wasn't remapped earlier.
1878        let source_indices: Vec<usize> = self
1879            .store
1880            .sources
1881            .iter()
1882            .enumerate()
1883            .filter(|(_, source)| {
1884                source.kind == SourceKind::PreIndexed
1885                    && match requested_source {
1886                        Some(name) => source.name.eq_ignore_ascii_case(name),
1887                        None => true,
1888                    }
1889            })
1890            .map(|(index, _)| index)
1891            .collect();
1892
1893        for source_index in source_indices {
1894            let needs: Vec<(usize, V2PackageMetadata)> = {
1895                let connection = match self.open_preindexed_connection(source_index) {
1896                    Ok(c) => c,
1897                    Err(_) => continue,
1898                };
1899                if !v2_identity_tables_present(&connection) {
1900                    continue;
1901                }
1902                let mut acc = Vec::new();
1903                for (idx, pkg) in installed.iter().enumerate() {
1904                    let Some(candidate) = pkg.correlated.as_ref() else {
1905                        continue;
1906                    };
1907                    // Stay within the source the row was correlated to —
1908                    // looking up `JetBrains.Rider` in a different catalog
1909                    // would either miss or pull the wrong package's range.
1910                    if !candidate
1911                        .source_name
1912                        .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1913                    {
1914                        continue;
1915                    }
1916                    let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1917                        continue;
1918                    };
1919                    acc.push((idx, meta));
1920                }
1921                acc
1922            };
1923
1924            let source = self.source_clone(source_index);
1925            for (idx, meta) in needs {
1926                // Load versionData once: we may need it for both the
1927                // aMiV/aMaV remap (only if installed_version isn't already
1928                // canonical) and to locate the latest manifest for the
1929                // RequireExplicitUpgrade flag.
1930                let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1931                    Ok((entries, _)) => entries,
1932                    Err(_) => continue,
1933                };
1934
1935                if !installed[idx].installed_version_canonical {
1936                    let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1937                        .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version))
1938                        .or_else(|| {
1939                            greater_than_latest_arp_anchored_version(
1940                                &entries,
1941                                &installed[idx].installed_version,
1942                                &meta.latest_version,
1943                            )
1944                        });
1945                    let anchored_latest = latest_arp_anchored_version(&entries);
1946                    if let Some(canonical) = canonical_installed {
1947                        let greater_than_latest = is_greater_than_version_marker(&canonical);
1948                        installed[idx].installed_version = canonical;
1949                        installed[idx].installed_version_canonical = true;
1950                        if let Some(correlated) = installed[idx].correlated.as_mut() {
1951                            correlated.version = if greater_than_latest { None } else { anchored_latest };
1952                        }
1953                    }
1954                }
1955
1956                // RequireExplicitUpgrade is set on the installer manifest
1957                // for the latest catalog version. winget hides these from
1958                // bulk `upgrade` (Edge, Steam, Discord and others). We
1959                // peek at the first versionData entry (latest by sort
1960                // order) and read its manifest to mirror that filtering.
1961                if let Some(latest) = entries.first() {
1962                    let bytes_result = self.get_cached_source_file(
1963                        "V2_M",
1964                        &source,
1965                        &latest.manifest_relative_path,
1966                        Some(latest.manifest_hash.as_str()),
1967                    );
1968                    if let Ok(bytes) = bytes_result
1969                        && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1970                    {
1971                        installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1972                        installed[idx].correlated_lacks_compatible_installer =
1973                            !manifest_has_compatible_installer(&manifest);
1974                    }
1975                }
1976            }
1977        }
1978
1979        Ok(Vec::new())
1980    }
1981
1982    pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1983        let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1984        let versions = self.versions_for_match(&located, query)?;
1985        Ok(VersionsResult {
1986            package: located.display,
1987            versions,
1988            warnings,
1989        })
1990    }
1991
1992    pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1993        let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
1994        let versions = self.versions_for_match(&located, query)?;
1995        Ok(VersionsResult {
1996            package: located.display,
1997            versions,
1998            warnings,
1999        })
2000    }
2001
2002    pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
2003        let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
2004        let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
2005        let selected_installer = select_installer(&manifest.installers, query);
2006
2007        Ok(ShowResult {
2008            package: located.display,
2009            manifest,
2010            selected_installer,
2011            cached_files,
2012            warnings,
2013            manifest_documents,
2014        })
2015    }
2016
2017    pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
2018        let (located, warnings) = self.find_single_match(query)?;
2019        let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
2020
2021        Ok(CacheWarmResult {
2022            package: located.display,
2023            cached_files,
2024            warnings,
2025        })
2026    }
2027
2028    // ── Pin management ──
2029
2030    pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
2031        let db_path = pins_db_path(&self.app_root);
2032        if !db_path.exists() {
2033            return Ok(Vec::new());
2034        }
2035        let conn = Self::open_sqlite_connection(db_path)?;
2036        let pin_type_column = match resolve_pin_type_column(&conn)? {
2037            Some(column) => column,
2038            None => return Ok(Vec::new()),
2039        };
2040        let sql = if source_id.is_some() {
2041            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
2042        } else {
2043            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
2044        };
2045        let params = if let Some(source_id) = source_id {
2046            vec![SqlValue::Text(source_id.to_owned())]
2047        } else {
2048            Vec::new()
2049        };
2050        query_rows(&conn, &sql, params, |row| {
2051            let pin_type_int = row_i64(row, 3)?;
2052            Ok(PinRecord {
2053                package_id: row_string(row, 0)?,
2054                version: row_string(row, 1)?,
2055                source_id: row_string(row, 2)?,
2056                pin_type: decode_pin_type(pin_type_int),
2057            })
2058        })
2059    }
2060
2061    pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
2062        let db_path = pins_db_path(&self.app_root);
2063        let conn = Self::open_sqlite_connection(db_path)?;
2064        execute_batch_sql(
2065            &conn,
2066            "CREATE TABLE IF NOT EXISTS pin (
2067                package_id TEXT NOT NULL,
2068                version TEXT NOT NULL DEFAULT '*',
2069                source_id TEXT NOT NULL DEFAULT '',
2070                type INTEGER NOT NULL DEFAULT 0,
2071                PRIMARY KEY (package_id, source_id)
2072            )",
2073        )?;
2074        let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
2075        let type_int: i64 = match pin_type {
2076            PinType::Pinning => 2,
2077            PinType::Blocking => 4,
2078            PinType::Gating => 3,
2079        };
2080        execute_sql(
2081            &conn,
2082            &format!(
2083                "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
2084            ),
2085            vec![
2086                SqlValue::Text(package_id.to_owned()),
2087                SqlValue::Text(version.to_owned()),
2088                SqlValue::Text(source_id.to_owned()),
2089                SqlValue::Integer(type_int),
2090            ],
2091        )?;
2092        conn.cacheflush().context("failed to flush pin database")?;
2093        Ok(())
2094    }
2095
2096    pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
2097        let db_path = pins_db_path(&self.app_root);
2098        if !db_path.exists() {
2099            return Ok(false);
2100        }
2101        let conn = Self::open_sqlite_connection(db_path)?;
2102        let count = if let Some(source_id) = source_id {
2103            execute_sql(
2104                &conn,
2105                "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
2106                vec![
2107                    SqlValue::Text(package_id.to_owned()),
2108                    SqlValue::Text(source_id.to_owned()),
2109                ],
2110            )?
2111        } else {
2112            execute_sql(
2113                &conn,
2114                "DELETE FROM pin WHERE package_id = ?1",
2115                vec![SqlValue::Text(package_id.to_owned())],
2116            )?
2117        };
2118        conn.cacheflush().context("failed to flush pin database")?;
2119        Ok(count > 0)
2120    }
2121
2122    pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
2123        let db_path = pins_db_path(&self.app_root);
2124        if !db_path.exists() {
2125            return Ok(());
2126        }
2127
2128        if let Some(source_id) = source_id {
2129            let conn = Self::open_sqlite_connection(db_path)?;
2130            execute_sql(
2131                &conn,
2132                "DELETE FROM pin WHERE source_id = ?1",
2133                vec![SqlValue::Text(source_id.to_owned())],
2134            )?;
2135            conn.cacheflush().context("failed to flush pin database")?;
2136        } else {
2137            fs::remove_file(&db_path)?;
2138        }
2139        Ok(())
2140    }
2141
2142    fn save_store(&self) -> Result<()> {
2143        if self.mirrors_system_winget_sources() {
2144            save_system_winget_mirror_store(&self.app_root, &self.store)
2145        } else {
2146            save_store(&self.app_root, &self.store)
2147        }
2148    }
2149
2150    fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
2151        source_state_dir_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2152    }
2153
2154    fn source_installed_db_path(&self, source: &SourceRecord) -> PathBuf {
2155        source_installed_db_path_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2156    }
2157
2158    fn preindexed_package_path(&self, source: &SourceRecord) -> PathBuf {
2159        self.source_state_dir(source).join("source.msix")
2160    }
2161
2162    fn preindexed_index_path(&self, source: &SourceRecord) -> PathBuf {
2163        self.source_state_dir(source).join("index.db")
2164    }
2165
2166    fn rest_information_cache_path(&self, source: &SourceRecord) -> PathBuf {
2167        self.source_state_dir(source).join("rest-information.json")
2168    }
2169
2170    fn rest_manifest_cache_path(
2171        &self,
2172        source: &SourceRecord,
2173        package_id: &str,
2174        version: &str,
2175        channel: &str,
2176    ) -> PathBuf {
2177        let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
2178        let digest = sha256_hex(key.as_bytes());
2179        cache_root(&self.app_root)
2180            .join("REST_M")
2181            .join(sanitize_path_segment(&source.identifier))
2182            .join(format!("{digest}.json"))
2183    }
2184
2185    // ── Install / download ──
2186
2187    pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
2188        self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
2189    }
2190
2191    pub fn download_installer_for_request(
2192        &mut self,
2193        request: &InstallRequest,
2194        download_dir: &Path,
2195    ) -> Result<DownloadOutput> {
2196        let fetched = self.fetch_installer_for_request(request, download_dir)?;
2197        let manifest_yaml =
2198            build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
2199                .context("failed to render manifest YAML")?;
2200        let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
2201        let manifest_path = download_dir.join(&manifest_filename);
2202        fs::write(&manifest_path, manifest_yaml.as_bytes())
2203            .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
2204
2205        Ok(DownloadOutput {
2206            manifest: fetched.manifest,
2207            installer_path: fetched.installer_path,
2208            manifest_path,
2209        })
2210    }
2211
2212    /// Fetches the installer file without emitting the YAML manifest. Used by
2213    /// the install flow, which downloads into a temp dir and only needs the
2214    /// installer path — emitting a manifest there would litter the temp dir
2215    /// and turn a benign YAML-write failure into an installation failure.
2216    fn fetch_installer_for_request(
2217        &mut self,
2218        request: &InstallRequest,
2219        download_dir: &Path,
2220    ) -> Result<FetchedInstaller> {
2221        let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
2222        let installer = select_installer(&manifest.installers, &request.query)
2223            .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
2224        let url = installer
2225            .url
2226            .as_deref()
2227            .ok_or_else(|| anyhow!("Installer has no URL"))?;
2228
2229        fs::create_dir_all(download_dir)?;
2230        let filename = request.rename.as_deref().unwrap_or_else(|| {
2231            url.rsplit('/')
2232                .next()
2233                .unwrap_or("installer")
2234                .split('?')
2235                .next()
2236                .unwrap_or("installer")
2237        });
2238        let dest = download_dir.join(filename);
2239
2240        self.emit_install_progress(InstallProgress::DownloadingInstaller {
2241            url: url.to_owned(),
2242            path: dest.clone(),
2243        });
2244        let actual_hash = download_installer_to_file(&self.user_agent, url, &dest)?;
2245
2246        self.emit_install_progress(InstallProgress::VerifyingInstaller { path: dest.clone() });
2247        if let Err(error) =
2248            verify_installer_hash_hex(installer.sha256.as_deref(), &actual_hash, request.ignore_security_hash)
2249        {
2250            let _ = fs::remove_file(&dest);
2251            return Err(error);
2252        }
2253
2254        Ok(FetchedInstaller {
2255            manifest,
2256            manifest_documents,
2257            installer,
2258            installer_path: dest,
2259        })
2260    }
2261
2262    pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2263        let mut request = InstallRequest::new(query.clone());
2264        request.mode = if silent {
2265            InstallerMode::Silent
2266        } else {
2267            InstallerMode::SilentWithProgress
2268        };
2269        self.install_request(&request)
2270    }
2271
2272    pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
2273        let mut request = InstallRequest::new(query.clone());
2274        request.mode = mode;
2275        self.install_request(&request)
2276    }
2277
2278    pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
2279        self.emit_install_progress(InstallProgress::ResolvingManifest);
2280        let manifest = self.resolve_manifest_for_install(request)?;
2281        self.emit_install_progress(InstallProgress::ResolvedManifest {
2282            package_id: manifest.id.clone(),
2283            version: manifest.version.clone(),
2284        });
2285        if !package_actions_supported() {
2286            let installer_type = select_installer(&manifest.installers, &request.query)
2287                .and_then(|installer| installer.installer_type)
2288                .unwrap_or_else(|| "install".to_owned())
2289                .to_lowercase();
2290            return Ok(unsupported_action_result(
2291                manifest.id.clone(),
2292                manifest.version.clone(),
2293                installer_type,
2294                INSTALL_UNSUPPORTED_WARNING,
2295            ));
2296        }
2297
2298        self.emit_install_progress(InstallProgress::CheckingInstalled);
2299        let existing_match = self.find_installed_package_for_install(request, &manifest)?;
2300        if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
2301            return Ok(no_op_result);
2302        }
2303
2304        Self::ensure_package_agreements_accepted(&manifest, request)?;
2305        self.emit_install_progress(InstallProgress::InstallingDependencies);
2306        self.install_dependencies(&manifest, request, &mut HashSet::new())?;
2307
2308        if request.dependencies_only {
2309            return Ok(InstallResult {
2310                package_id: manifest.id.clone(),
2311                version: manifest.version.clone(),
2312                installer_path: PathBuf::new(),
2313                installer_type: "dependencies".to_owned(),
2314                exit_code: 0,
2315                success: true,
2316                no_op: false,
2317                warnings: Vec::new(),
2318            });
2319        }
2320
2321        self.emit_install_progress(InstallProgress::SelectingInstaller);
2322        let installer = select_installer(&manifest.installers, &request.query)
2323            .ok_or_else(|| anyhow!("No applicable installer found"))?;
2324
2325        if request.uninstall_previous {
2326            let mut uninstall_request = UninstallRequest::new(PackageQuery {
2327                id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2328                query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
2329                source: request.query.source.clone(),
2330                exact: true,
2331                version: request.query.version.clone(),
2332                ..PackageQuery::default()
2333            });
2334            uninstall_request.product_code = installer.product_code.clone();
2335            uninstall_request.mode = InstallerMode::Silent;
2336            uninstall_request.all_versions = true;
2337            uninstall_request.force = true;
2338            let _ = self.uninstall_request(&uninstall_request);
2339        }
2340
2341        let temp_dir = std::env::temp_dir().join("pinget-install");
2342        let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
2343
2344        let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
2345
2346        self.emit_install_progress(InstallProgress::StartingInstaller {
2347            installer_type: installer_type.clone(),
2348            path: installer_path.clone(),
2349        });
2350        self.emit_install_progress(InstallProgress::WaitingForInstaller {
2351            installer_type: installer_type.clone(),
2352        });
2353        let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
2354        self.emit_install_progress(InstallProgress::InstallerFinished { exit_code });
2355
2356        Ok(InstallResult {
2357            package_id: manifest.id.clone(),
2358            version: manifest.version.clone(),
2359            installer_path,
2360            installer_type,
2361            exit_code,
2362            success: exit_code == 0,
2363            no_op: false,
2364            warnings: Vec::new(),
2365        })
2366    }
2367
2368    fn find_installed_package_for_install(
2369        &mut self,
2370        request: &InstallRequest,
2371        manifest: &Manifest,
2372    ) -> Result<Option<ListMatch>> {
2373        let installed_matches = self.list(&ListQuery {
2374            query: request.query.query.clone(),
2375            id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2376            name: request.query.name.clone(),
2377            moniker: request.query.moniker.clone(),
2378            tag: None,
2379            command: None,
2380            product_code: None,
2381            version: None,
2382            source: request.query.source.clone(),
2383            count: Some(100),
2384            exact: request.query.exact || !manifest.id.trim().is_empty(),
2385            install_scope: request.query.install_scope.clone(),
2386            upgrade_only: false,
2387            include_unknown: false,
2388            include_pinned: false,
2389        })?;
2390
2391        Ok(installed_matches.matches.into_iter().find(|candidate| {
2392            candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
2393        }))
2394    }
2395
2396    fn create_install_no_op_result(
2397        request: &InstallRequest,
2398        manifest: &Manifest,
2399        existing_match: Option<&ListMatch>,
2400    ) -> Option<InstallResult> {
2401        let existing_match = existing_match?;
2402
2403        if request.no_upgrade {
2404            return Some(InstallResult {
2405                package_id: manifest.id.clone(),
2406                version: existing_match.installed_version.clone(),
2407                installer_path: PathBuf::new(),
2408                installer_type: "install".to_owned(),
2409                exit_code: 0,
2410                success: true,
2411                no_op: true,
2412                warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
2413            });
2414        }
2415
2416        if !request.force
2417            && !existing_match.installed_version.trim().is_empty()
2418            && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
2419        {
2420            return Some(InstallResult {
2421                package_id: manifest.id.clone(),
2422                version: existing_match.installed_version.clone(),
2423                installer_path: PathBuf::new(),
2424                installer_type: "install".to_owned(),
2425                exit_code: 0,
2426                success: true,
2427                no_op: true,
2428                warnings: vec![
2429                    "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
2430                ],
2431            });
2432        }
2433
2434        None
2435    }
2436
2437    pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
2438        if request.manifest_path.is_none() && !package_actions_supported() {
2439            let package_id = request
2440                .query
2441                .id
2442                .clone()
2443                .or_else(|| request.query.name.clone())
2444                .or_else(|| request.query.moniker.clone())
2445                .or_else(|| request.query.query.clone())
2446                .or_else(|| request.product_code.clone())
2447                .unwrap_or_else(|| "repair".to_owned());
2448            return Ok(unsupported_action_result(
2449                package_id,
2450                request.query.version.clone().unwrap_or_default(),
2451                "repair".to_owned(),
2452                REPAIR_UNSUPPORTED_WARNING,
2453            ));
2454        }
2455
2456        let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2457        let installed_match = if request.manifest_path.is_some() {
2458            None
2459        } else {
2460            let installed = self.list(&Self::create_repair_list_query(request))?;
2461            warnings.extend(installed.warnings);
2462            if installed.matches.is_empty() {
2463                bail!("No installed package matched the supplied repair query.");
2464            }
2465            if installed.matches.len() > 1 {
2466                bail!("Multiple installed packages matched the supplied repair query.");
2467            }
2468            installed.matches.into_iter().next()
2469        };
2470
2471        let mut result =
2472            self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2473        warnings.extend(result.warnings);
2474        result.warnings = warnings;
2475        Ok(result)
2476    }
2477
2478    pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2479        let mut request = UninstallRequest::new(query.clone());
2480        request.mode = if silent {
2481            InstallerMode::Silent
2482        } else {
2483            InstallerMode::SilentWithProgress
2484        };
2485        self.uninstall_request(&request)
2486    }
2487
2488    pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2489        if !package_actions_supported() {
2490            let (package_id, version) = if let Some(path) = &request.manifest_path {
2491                let manifest = Self::load_manifest_from_path(path)?;
2492                (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2493            } else {
2494                (
2495                    request
2496                        .query
2497                        .id
2498                        .clone()
2499                        .or_else(|| request.query.query.clone())
2500                        .or_else(|| request.query.name.clone())
2501                        .or_else(|| request.query.moniker.clone())
2502                        .or_else(|| request.product_code.clone())
2503                        .unwrap_or_else(|| "unknown".to_owned()),
2504                    request.query.version.clone().unwrap_or_default(),
2505                )
2506            };
2507
2508            return Ok(unsupported_action_result(
2509                package_id,
2510                version,
2511                "uninstall",
2512                UNINSTALL_UNSUPPORTED_WARNING,
2513            ));
2514        }
2515
2516        let matches = self.resolve_uninstall_matches(request)?;
2517        let mut exit_code = 0;
2518
2519        for installed in &matches {
2520            exit_code = uninstall_package(installed, request)?;
2521            if exit_code != 0 && !request.all_versions {
2522                break;
2523            }
2524        }
2525
2526        let installed = matches
2527            .first()
2528            .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2529
2530        Ok(InstallResult {
2531            package_id: installed.id.clone(),
2532            version: installed.installed_version.clone(),
2533            installer_path: PathBuf::new(),
2534            installer_type: "uninstall".to_owned(),
2535            exit_code,
2536            success: exit_code == 0,
2537            no_op: false,
2538            warnings: Vec::new(),
2539        })
2540    }
2541
2542    fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2543        self.resolve_manifest_and_documents_for_install(request)
2544            .map(|(manifest, _)| manifest)
2545    }
2546
2547    fn resolve_manifest_and_documents_for_install(
2548        &mut self,
2549        request: &InstallRequest,
2550    ) -> Result<(Manifest, JsonValue)> {
2551        if let Some(path) = &request.manifest_path {
2552            return Self::load_manifest_from_path_with_documents(path);
2553        }
2554
2555        let (located, _warnings) = self.find_single_match(&request.query)?;
2556        let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2557        Ok((manifest, documents))
2558    }
2559
2560    fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2561        if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2562            bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2563        }
2564        Ok(())
2565    }
2566
2567    fn install_dependencies(
2568        &mut self,
2569        manifest: &Manifest,
2570        request: &InstallRequest,
2571        visited: &mut HashSet<String>,
2572    ) -> Result<()> {
2573        if request.skip_dependencies {
2574            return Ok(());
2575        }
2576
2577        let dependency_ids = manifest
2578            .package_dependencies
2579            .iter()
2580            .chain(
2581                manifest
2582                    .installers
2583                    .iter()
2584                    .flat_map(|installer| installer.package_dependencies.iter()),
2585            )
2586            .filter(|value| !value.trim().is_empty())
2587            .cloned()
2588            .collect::<BTreeSet<_>>();
2589
2590        for dependency_id in dependency_ids {
2591            if !visited.insert(dependency_id.clone()) {
2592                continue;
2593            }
2594
2595            let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2596            self.install_request(&dependency_request)?;
2597        }
2598
2599        Ok(())
2600    }
2601
2602    fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2603        let mut dependency_request = InstallRequest::new(PackageQuery {
2604            id: Some(dependency_id),
2605            source: request
2606                .dependency_source
2607                .clone()
2608                .or_else(|| request.query.source.clone()),
2609            exact: true,
2610            ..PackageQuery::default()
2611        });
2612        dependency_request.mode = match request.mode {
2613            InstallerMode::Interactive => InstallerMode::Interactive,
2614            InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2615        };
2616        dependency_request.accept_package_agreements = request.accept_package_agreements;
2617        dependency_request.no_upgrade = true;
2618        dependency_request.ignore_security_hash = request.ignore_security_hash;
2619        dependency_request.dependency_source = request.dependency_source.clone();
2620        dependency_request
2621    }
2622
2623    fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2624        ListQuery {
2625            query: request.query.query.clone(),
2626            id: request.query.id.clone(),
2627            name: request.query.name.clone(),
2628            moniker: request.query.moniker.clone(),
2629            product_code: request.product_code.clone(),
2630            version: request.query.version.clone(),
2631            source: request.query.source.clone(),
2632            count: Some(100),
2633            exact: request.query.exact,
2634            install_scope: request.query.install_scope.clone(),
2635            ..ListQuery::default()
2636        }
2637    }
2638
2639    fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2640        let mut query = request.query.clone();
2641        query.query = if installed_match.is_none() {
2642            request.query.query.clone()
2643        } else {
2644            None
2645        };
2646        query.id = installed_match
2647            .map(|item| item.id.clone())
2648            .or_else(|| request.query.id.clone());
2649        query.name = if installed_match.is_none() {
2650            request.query.name.clone()
2651        } else {
2652            None
2653        };
2654        query.moniker = if installed_match.is_none() {
2655            request.query.moniker.clone()
2656        } else {
2657            None
2658        };
2659        query.source = request
2660            .query
2661            .source
2662            .clone()
2663            .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2664        query.exact = true;
2665        query.version = request
2666            .query
2667            .version
2668            .clone()
2669            .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2670
2671        InstallRequest {
2672            query,
2673            manifest_path: request.manifest_path.clone(),
2674            mode: request.mode,
2675            log_path: request.log_path.clone(),
2676            custom: None,
2677            override_args: None,
2678            install_location: None,
2679            skip_dependencies: false,
2680            dependencies_only: false,
2681            accept_package_agreements: request.accept_package_agreements,
2682            force: true,
2683            rename: None,
2684            uninstall_previous: false,
2685            ignore_security_hash: request.ignore_security_hash,
2686            dependency_source: None,
2687            no_upgrade: false,
2688        }
2689    }
2690
2691    fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2692        let mut effective_query = request.query.clone();
2693        let mut effective_product_code = request.product_code.clone();
2694
2695        if let Some(path) = &request.manifest_path {
2696            let manifest = Self::load_manifest_from_path(path)?;
2697            effective_query = PackageQuery {
2698                query: Some(manifest.id.clone()),
2699                id: Some(manifest.id.clone()),
2700                name: Some(manifest.name.clone()),
2701                exact: true,
2702                version: effective_query.version.clone().or(Some(manifest.version)),
2703                ..PackageQuery::default()
2704            };
2705            effective_product_code = effective_product_code.or_else(|| {
2706                manifest
2707                    .installers
2708                    .iter()
2709                    .find_map(|installer| installer.product_code.clone())
2710            });
2711        }
2712
2713        let list_query = ListQuery {
2714            query: effective_query.query.clone(),
2715            id: effective_query.id.clone(),
2716            name: effective_query.name.clone(),
2717            moniker: effective_query.moniker.clone(),
2718            tag: None,
2719            command: None,
2720            product_code: effective_product_code,
2721            version: effective_query.version.clone(),
2722            source: effective_query.source.clone(),
2723            count: if request.all_versions { None } else { Some(100) },
2724            exact: effective_query.exact,
2725            install_scope: effective_query.install_scope,
2726            upgrade_only: false,
2727            include_unknown: false,
2728            include_pinned: false,
2729        };
2730
2731        let list_result = self.list(&list_query)?;
2732        if list_result.matches.is_empty() {
2733            bail!("No installed package found matching the query");
2734        }
2735        if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2736            bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2737        }
2738
2739        Ok(if request.all_versions {
2740            list_result.matches
2741        } else {
2742            vec![list_result.matches[0].clone()]
2743        })
2744    }
2745
2746    fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2747        Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2748    }
2749
2750    fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2751        let resolved = resolve_manifest_path(manifest_path)?;
2752        let bytes =
2753            fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2754        parse_yaml_manifest_bundle(&bytes)
2755    }
2756
2757    fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2758        self.find_single_match_with_semantics(query, SearchSemantics::Single)
2759    }
2760
2761    fn find_single_match_for_show(
2762        &mut self,
2763        query: &PackageQuery,
2764        semantics: SearchSemantics,
2765    ) -> Result<(LocatedMatch, Vec<String>)> {
2766        let located = self.search_located(query, semantics)?;
2767        if self.should_retry_show_across_system_winget_sources(query, &located) {
2768            let mut fallback_query = query.clone();
2769            fallback_query.source = None;
2770            return single_match_from_located(self.search_located(&fallback_query, semantics)?, false);
2771        }
2772
2773        single_match_from_located(located, query.source.is_some())
2774    }
2775
2776    fn should_retry_show_across_system_winget_sources(
2777        &self,
2778        query: &PackageQuery,
2779        located: &SearchLocatedResult,
2780    ) -> bool {
2781        should_retry_show_across_system_winget_sources(self.uses_system_winget_sources(), query, located)
2782    }
2783
2784    fn find_single_match_with_semantics(
2785        &mut self,
2786        query: &PackageQuery,
2787        semantics: SearchSemantics,
2788    ) -> Result<(LocatedMatch, Vec<String>)> {
2789        let located = self.search_located(query, semantics)?;
2790        single_match_from_located(located, query.source.is_some())
2791    }
2792
2793    fn search_located(&mut self, query: &PackageQuery, semantics: SearchSemantics) -> Result<SearchLocatedResult> {
2794        let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2795        let mut matches = Vec::new();
2796        let mut warnings = Vec::new();
2797        let mut failures = Vec::new();
2798        let mut truncated = false;
2799
2800        for index in indexes {
2801            match self.search_source(index, query, semantics) {
2802                Ok(mut source_matches) => {
2803                    truncated |= source_matches.truncated;
2804                    matches.append(&mut source_matches.matches);
2805                }
2806                Err(error) => {
2807                    let source_name = self.store.sources[index].name.clone();
2808                    let message = format!("{error:#}");
2809                    warnings.push(format!(
2810                        "Failed when searching source '{}'; results will not be included: {}",
2811                        source_name, message
2812                    ));
2813                    failures.push(SourceSearchFailure { source_name, message });
2814                }
2815            }
2816        }
2817
2818        if semantics == SearchSemantics::Many {
2819            matches.sort_by(|left, right| {
2820                search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2821            });
2822            let limit = max_results(query);
2823            if matches.len() > limit {
2824                truncated = true;
2825                matches.truncate(limit);
2826            }
2827        }
2828
2829        Ok(SearchLocatedResult {
2830            matches,
2831            warnings,
2832            failures,
2833            truncated,
2834        })
2835    }
2836
2837    fn search_source(
2838        &mut self,
2839        source_index: usize,
2840        query: &PackageQuery,
2841        semantics: SearchSemantics,
2842    ) -> Result<SearchSourceMatches> {
2843        match self.store.sources[source_index].kind {
2844            SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2845            SourceKind::Rest => self.search_rest(source_index, query, semantics),
2846        }
2847    }
2848
2849    fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2850        match &located.locator {
2851            MatchLocator::PreIndexedV1 { package_rowid } => {
2852                let source = self.source_clone(located.source_index);
2853                let connection = self.open_preindexed_connection(located.source_index)?;
2854                let versions = query_v1_versions(&connection, *package_rowid)?;
2855                let mut keys = versions
2856                    .into_iter()
2857                    .map(|row| VersionKey {
2858                        version: row.version,
2859                        channel: row.channel,
2860                    })
2861                    .collect::<Vec<_>>();
2862                sort_versions_desc(&mut keys);
2863                if keys.is_empty() {
2864                    bail!("no versions found for {} in {}", located.display.id, source.name);
2865                }
2866                Ok(keys)
2867            }
2868            MatchLocator::PreIndexedV2 {
2869                package_rowid,
2870                package_hash,
2871            } => {
2872                let source = self.source_clone(located.source_index);
2873                let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2874                let mut keys = entries
2875                    .into_iter()
2876                    .map(|entry| VersionKey {
2877                        version: entry.version,
2878                        channel: String::new(),
2879                    })
2880                    .collect::<Vec<_>>();
2881                sort_versions_desc(&mut keys);
2882                Ok(keys)
2883            }
2884            MatchLocator::Rest { versions, .. } => {
2885                let mut keys = versions.clone();
2886                sort_versions_desc(&mut keys);
2887                Ok(keys)
2888            }
2889        }
2890    }
2891
2892    fn manifest_for_match(
2893        &mut self,
2894        located: &LocatedMatch,
2895        query: &PackageQuery,
2896    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2897        self.manifest_for_match_with_refresh(located, query, true)
2898    }
2899
2900    fn manifest_for_match_with_refresh(
2901        &mut self,
2902        located: &LocatedMatch,
2903        query: &PackageQuery,
2904        allow_explicit_version_refresh: bool,
2905    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2906        match &located.locator {
2907            MatchLocator::PreIndexedV1 { package_rowid } => {
2908                let source = self.source_clone(located.source_index);
2909                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2910                let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2911                let selected = match select_v1_version(
2912                    &versions,
2913                    query.version.as_deref(),
2914                    query.channel.as_deref(),
2915                    &located.display.id,
2916                    &source.name,
2917                ) {
2918                    Ok(selected) => selected,
2919                    Err(error)
2920                        if allow_explicit_version_refresh
2921                            && query.version.is_some()
2922                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2923                    {
2924                        let open_refresh = opened.refresh.clone();
2925                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2926                            return missing_error_after_open_refresh(error, &open_refresh);
2927                        }
2928                        if self.has_recent_successful_preindexed_refresh_attempt(
2929                            &self.preindexed_refresh_lock_key(&source),
2930                        ) {
2931                            return Err(error);
2932                        }
2933                        drop(opened);
2934                        return self.refresh_preindexed_and_retry_manifest(
2935                            located.source_index,
2936                            &located.display,
2937                            query,
2938                            error,
2939                        );
2940                    }
2941                    Err(error) => return Err(error),
2942                };
2943                let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2944                let bytes =
2945                    self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2946                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2947                manifest.version = selected.version.clone();
2948                manifest.channel = selected.channel;
2949                Ok((manifest, manifest_documents, vec![bytes.path]))
2950            }
2951            MatchLocator::PreIndexedV2 {
2952                package_rowid,
2953                package_hash,
2954            } => {
2955                let source = self.source_clone(located.source_index);
2956                let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2957                let (entries, version_data_file) =
2958                    self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2959                let selected = match select_v2_version(
2960                    &entries,
2961                    query.version.as_deref(),
2962                    query.channel.as_deref(),
2963                    &located.display.id,
2964                    &source.name,
2965                ) {
2966                    Ok(selected) => selected,
2967                    Err(error)
2968                        if allow_explicit_version_refresh
2969                            && query.version.is_some()
2970                            && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2971                    {
2972                        let open_refresh = opened.refresh.clone();
2973                        if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2974                            return missing_error_after_open_refresh(error, &open_refresh);
2975                        }
2976                        if self.has_recent_successful_preindexed_refresh_attempt(
2977                            &self.preindexed_refresh_lock_key(&source),
2978                        ) {
2979                            return Err(error);
2980                        }
2981                        drop(opened);
2982                        return self.refresh_preindexed_and_retry_manifest(
2983                            located.source_index,
2984                            &located.display,
2985                            query,
2986                            error,
2987                        );
2988                    }
2989                    Err(error) => return Err(error),
2990                };
2991                let manifest_bytes = self.get_cached_source_file(
2992                    "V2_M",
2993                    &source,
2994                    &selected.manifest_relative_path,
2995                    Some(selected.manifest_hash.as_str()),
2996                )?;
2997                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2998                manifest.version = selected.version;
2999                Ok((
3000                    manifest,
3001                    manifest_documents,
3002                    vec![version_data_file, manifest_bytes.path],
3003                ))
3004            }
3005            MatchLocator::Rest { package_id, versions } => {
3006                let source = self.source_clone(located.source_index);
3007                let selected = select_rest_version(
3008                    versions,
3009                    query.version.as_deref(),
3010                    query.channel.as_deref(),
3011                    &located.display.id,
3012                    &source.name,
3013                )?;
3014                let (bytes, cache_path) =
3015                    self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
3016                let (manifest, manifest_documents) =
3017                    parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
3018                Ok((manifest, manifest_documents, vec![cache_path]))
3019            }
3020        }
3021    }
3022
3023    fn refresh_preindexed_and_retry_manifest(
3024        &mut self,
3025        source_index: usize,
3026        previous_display: &SearchMatch,
3027        query: &PackageQuery,
3028        missing_error: anyhow::Error,
3029    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
3030        self.refresh_preindexed_source(source_index, true)
3031            .with_context(|| format!("{missing_error} The source refresh also failed"))?;
3032        let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
3033        self.manifest_for_match_with_refresh(&refreshed, query, false)
3034    }
3035
3036    fn find_refreshed_preindexed_match(
3037        &mut self,
3038        source_index: usize,
3039        previous_display: &SearchMatch,
3040        query: &PackageQuery,
3041    ) -> Result<LocatedMatch> {
3042        let source_name = self.store.sources[source_index].name.clone();
3043        let mut source_query = query.clone();
3044        source_query.source = Some(source_name.clone());
3045        let matches = self
3046            .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
3047            .matches;
3048        if let Some(found) = matches
3049            .iter()
3050            .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
3051        {
3052            return Ok(found.clone());
3053        }
3054        if matches.len() == 1 {
3055            return Ok(matches.into_iter().next().expect("single match"));
3056        }
3057        bail!(
3058            "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
3059            previous_display.id,
3060            source_name
3061        )
3062    }
3063
3064    fn search_preindexed(
3065        &mut self,
3066        source_index: usize,
3067        query: &PackageQuery,
3068        semantics: SearchSemantics,
3069    ) -> Result<SearchSourceMatches> {
3070        let connection = self.open_preindexed_connection(source_index)?;
3071        let source = self.source_clone(source_index);
3072
3073        match query_v2_matches(&connection, query, semantics) {
3074            Ok((rows, truncated)) => Ok(SearchSourceMatches {
3075                truncated,
3076                matches: rows
3077                    .into_iter()
3078                    .map(|row| LocatedMatch {
3079                        display: SearchMatch {
3080                            source_name: source.name.clone(),
3081                            source_kind: source.kind,
3082                            id: row.id.clone(),
3083                            name: row.name.clone(),
3084                            moniker: row.moniker.clone(),
3085                            version: Some(row.version.clone()),
3086                            channel: None,
3087                            match_criteria: row.match_criteria.clone(),
3088                        },
3089                        source_index,
3090                        locator: MatchLocator::PreIndexedV2 {
3091                            package_rowid: row.package_rowid,
3092                            package_hash: row.package_hash,
3093                        },
3094                    })
3095                    .collect(),
3096            }),
3097            Err(error) if can_fallback_to_v1(&error) => {
3098                let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
3099                let grouped = group_v1_rows(rows);
3100
3101                Ok(SearchSourceMatches {
3102                    truncated,
3103                    matches: grouped
3104                        .into_iter()
3105                        .map(|row| LocatedMatch {
3106                            display: SearchMatch {
3107                                source_name: source.name.clone(),
3108                                source_kind: source.kind,
3109                                id: row.id.clone(),
3110                                name: row.name.clone(),
3111                                moniker: row.moniker.clone(),
3112                                version: Some(row.version.clone()),
3113                                channel: Some(row.channel.clone()),
3114                                match_criteria: row.match_criteria.clone(),
3115                            },
3116                            source_index,
3117                            locator: MatchLocator::PreIndexedV1 {
3118                                package_rowid: row.package_rowid,
3119                            },
3120                        })
3121                        .collect(),
3122                })
3123            }
3124            Err(error) => Err(error),
3125        }
3126    }
3127
3128    fn search_rest(
3129        &mut self,
3130        source_index: usize,
3131        query: &PackageQuery,
3132        semantics: SearchSemantics,
3133    ) -> Result<SearchSourceMatches> {
3134        let source = self.source_clone(source_index);
3135        let info = self.load_rest_information(source_index)?;
3136        let contract = choose_contract(&info.server_supported_versions)
3137            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3138
3139        let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
3140        let body = build_rest_search_body(query, &info, semantics)?;
3141        let response = self
3142            .client
3143            .post(url)
3144            .header("Version", contract)
3145            .header(CONTENT_TYPE, "application/json")
3146            .body(serde_json::to_vec(&body).context("failed to serialize REST search request")?)
3147            .send()
3148            .context("REST search request failed")?
3149            .error_for_status()
3150            .context("REST search request returned an error")?;
3151        let json =
3152            serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST search response")?)
3153                .context("failed to parse REST search response")?;
3154        let data = json
3155            .get("Data")
3156            .and_then(JsonValue::as_array)
3157            .cloned()
3158            .unwrap_or_default();
3159        let max_results = source_fetch_results(query, semantics);
3160
3161        let mut results = Vec::new();
3162        for item in data {
3163            let package_id = json_string(&item, "PackageIdentifier")
3164                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
3165            let package_name =
3166                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
3167            let mut versions = parse_rest_versions(&item)?;
3168            sort_versions_desc(&mut versions);
3169            let latest = versions
3170                .first()
3171                .cloned()
3172                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
3173
3174            results.push(LocatedMatch {
3175                display: SearchMatch {
3176                    source_name: source.name.clone(),
3177                    source_kind: source.kind,
3178                    id: package_id.clone(),
3179                    name: package_name,
3180                    moniker: json_string(&item, "Moniker"),
3181                    version: Some(latest.version.clone()),
3182                    channel: if latest.channel.is_empty() {
3183                        None
3184                    } else {
3185                        Some(latest.channel.clone())
3186                    },
3187                    match_criteria: rest_match_criteria(&item, query, semantics),
3188                },
3189                source_index,
3190                locator: MatchLocator::Rest { package_id, versions },
3191            });
3192        }
3193
3194        Ok(SearchSourceMatches {
3195            truncated: results.len() >= max_results,
3196            matches: results,
3197        })
3198    }
3199
3200    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
3201        Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
3202    }
3203
3204    fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
3205        let source = self.source_clone(source_index);
3206        let index_path = self.preindexed_index_path(&source);
3207        let mut refresh = PreindexedRefreshOutcome::default();
3208        if !index_path.exists() {
3209            let _ = self.refresh_preindexed_source(source_index, true)?;
3210            refresh.attempted = true;
3211        } else if self.is_preindexed_index_stale(&source, &index_path) {
3212            let lock_key = self.preindexed_refresh_lock_key(&source);
3213            if self.has_recent_preindexed_refresh_attempt(&lock_key) {
3214                refresh.attempted = true;
3215                refresh.reused_recent_attempt = true;
3216            } else {
3217                refresh.attempted = true;
3218                if let Err(error) = self.refresh_preindexed_source(source_index, false) {
3219                    refresh.error = Some(format!("{error:#}"));
3220                }
3221            }
3222        }
3223
3224        Ok(PreindexedOpenConnection {
3225            connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
3226            refresh,
3227        })
3228    }
3229
3230    fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
3231        let Some(interval) = self.pre_indexed_source_auto_update_interval else {
3232            return false;
3233        };
3234
3235        let index_modified = || {
3236            fs::metadata(index_path)
3237                .ok()
3238                .and_then(|metadata| metadata.modified().ok())
3239                .map(DateTime::<Utc>::from)
3240        };
3241
3242        let last_update = match (source.last_update, index_modified()) {
3243            (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
3244            (Some(source_last_update), None) => Some(source_last_update),
3245            (None, Some(index_last_write)) => Some(index_last_write),
3246            (None, None) => None,
3247        };
3248
3249        match last_update {
3250            Some(last_update) => Utc::now() - last_update >= interval,
3251            None => true,
3252        }
3253    }
3254
3255    fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
3256        format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
3257    }
3258
3259    fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3260        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3261            Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3262        })
3263    }
3264
3265    fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3266        self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3267            attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3268        })
3269    }
3270
3271    fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
3272        self.preindexed_refresh_attempts.insert(
3273            lock_key,
3274            PreindexedRefreshAttempt {
3275                attempted_at: Utc::now(),
3276                succeeded,
3277            },
3278        );
3279    }
3280
3281    fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
3282        {
3283            let locks = preindexed_refresh_locks()
3284                .read()
3285                .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3286            if let Some(existing) = locks.get(lock_key) {
3287                return Ok(Arc::clone(existing));
3288            }
3289        }
3290
3291        let mut locks = preindexed_refresh_locks()
3292            .write()
3293            .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3294        Ok(Arc::clone(
3295            locks
3296                .entry(lock_key.to_owned())
3297                .or_insert_with(|| Arc::new(Mutex::new(()))),
3298        ))
3299    }
3300
3301    fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
3302        let source = self.source_clone(source_index);
3303        let index_path = self.preindexed_index_path(&source);
3304        let lock_key = self.preindexed_refresh_lock_key(&source);
3305        let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
3306        let _guard = refresh_lock
3307            .lock()
3308            .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
3309
3310        if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
3311            return Ok("source already refreshed".to_owned());
3312        }
3313
3314        let result = self.update_preindexed(source_index);
3315        self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
3316        let detail = result?;
3317        self.save_store()?;
3318        Ok(detail)
3319    }
3320
3321    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
3322        let path_text = path.to_string_lossy().into_owned();
3323        let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
3324            .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
3325        database
3326            .connect()
3327            .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
3328    }
3329
3330    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
3331        let source_snapshot = self.store.sources[source_index].clone();
3332        let state_dir = self.source_state_dir(&source_snapshot);
3333        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
3334        let index_path = self.preindexed_index_path(&source_snapshot);
3335        let has_local_index = index_path.exists();
3336
3337        let mut last_error = None;
3338        for candidate in PREINDEXED_CANDIDATES {
3339            let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate);
3340            if has_local_index
3341                && let Some(detail) =
3342                    self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate)
3343            {
3344                return Ok(detail);
3345            }
3346
3347            let mut request = self.client.get(&url);
3348            if has_local_index {
3349                if let Some(etag) = &source_snapshot.etag
3350                    && let Ok(value) = HeaderValue::from_str(etag)
3351                {
3352                    request = request.header(IF_NONE_MATCH, value);
3353                }
3354                if let Some(last_modified) = &source_snapshot.last_modified
3355                    && let Ok(value) = HeaderValue::from_str(last_modified)
3356                {
3357                    request = request.header(IF_MODIFIED_SINCE, value);
3358                }
3359            }
3360
3361            match request.send() {
3362                Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => {
3363                    let source = &mut self.store.sources[source_index];
3364                    update_preindexed_http_validators(source, response.headers());
3365                    source.last_update = Some(Utc::now());
3366                    return Ok(format!("already up to date from {}", candidate));
3367                }
3368                Ok(response) if response.status().is_success() => {
3369                    let header_version = response
3370                        .headers()
3371                        .get("x-ms-meta-sourceversion")
3372                        .and_then(|value| value.to_str().ok())
3373                        .map(str::to_owned);
3374                    let headers = response.headers().clone();
3375                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
3376                    let payload = bytes.to_vec();
3377                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
3378                        .context("preindexed package did not contain Public/index.db")?;
3379
3380                    fs::write(self.preindexed_package_path(&source_snapshot), &payload)
3381                        .context("failed to persist source package")?;
3382                    let temp_index_path = state_dir.join(format!(
3383                        "index.{}.tmp",
3384                        Utc::now().timestamp_nanos_opt().unwrap_or_default()
3385                    ));
3386                    fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
3387                    if let Err(error) = replace_file(&temp_index_path, &index_path) {
3388                        let _ = fs::remove_file(&temp_index_path);
3389                        return Err(error).context("failed to persist source index");
3390                    }
3391                    // The new index.db is a fresh database; any WAL/SHM sidecars
3392                    // left from the previous one no longer match it and would make
3393                    // SQLite recover against the wrong database. Drop them so the
3394                    // next open starts clean.
3395                    remove_sqlite_sidecars(&index_path);
3396                    let source = &mut self.store.sources[source_index];
3397                    source.last_update = Some(Utc::now());
3398                    source.source_version = header_version;
3399                    update_preindexed_http_validators(source, &headers);
3400                    return Ok(format!("downloaded {}", candidate));
3401                }
3402                Ok(response) => {
3403                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3404                }
3405                Err(error) => {
3406                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3407                }
3408            }
3409        }
3410
3411        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3412    }
3413
3414    fn try_skip_unchanged_preindexed_by_source_version(
3415        &mut self,
3416        source_index: usize,
3417        url: &str,
3418        candidate: &str,
3419    ) -> Option<String> {
3420        let current_version = self.store.sources[source_index].source_version.as_deref()?;
3421        let response = self.client.head(url).send().ok()?;
3422        if !response.status().is_success() {
3423            return None;
3424        }
3425
3426        let header_version = response
3427            .headers()
3428            .get("x-ms-meta-sourceversion")
3429            .and_then(|value| value.to_str().ok())?;
3430        if !header_version.eq_ignore_ascii_case(current_version) {
3431            return None;
3432        }
3433
3434        let source = &mut self.store.sources[source_index];
3435        update_preindexed_http_validators(source, response.headers());
3436        source.last_update = Some(Utc::now());
3437        Some(format!("already up to date from {} (v{})", candidate, header_version))
3438    }
3439
3440    fn update_rest(&mut self, source_index: usize) -> Result<String> {
3441        let info = self.fetch_rest_information(source_index, true)?;
3442        let source = &mut self.store.sources[source_index];
3443        if !info.source_identifier.is_empty() {
3444            source.identifier = info.source_identifier.clone();
3445        }
3446        source.last_update = Some(Utc::now());
3447        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3448        Ok("refreshed information cache".to_owned())
3449    }
3450
3451    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3452        let source = self.source_clone(source_index);
3453        let cache_path = self.rest_information_cache_path(&source);
3454
3455        if cache_path.exists() {
3456            let cache = serde_json::from_slice::<RestInfoCache>(
3457                &fs::read(&cache_path).context("failed to read REST information cache")?,
3458            )
3459            .context("failed to parse REST information cache")?;
3460            if cache.expires_at > Utc::now() {
3461                return Ok(cache.value);
3462            }
3463        }
3464
3465        self.fetch_rest_information(source_index, true)
3466    }
3467
3468    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3469        let source = self.source_clone(source_index);
3470        let url = format!("{}/information", source.arg.trim_end_matches('/'));
3471        let response = self
3472            .client
3473            .get(url)
3474            .send()
3475            .context("REST information request failed")?
3476            .error_for_status()
3477            .context("REST information request returned an error")?;
3478
3479        let max_age = cache_control_max_age(&response);
3480        let json =
3481            serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST information response")?)
3482                .context("failed to parse REST information response")?;
3483        let data = json
3484            .get("Data")
3485            .cloned()
3486            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3487        let info = serde_json::from_value::<RestInformation>(data)
3488            .context("failed to deserialize REST information payload")?;
3489
3490        if persist {
3491            let cache = RestInfoCache {
3492                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3493                value: info.clone(),
3494            };
3495            write_json(self.rest_information_cache_path(&source), &cache)?;
3496        }
3497
3498        Ok(info)
3499    }
3500
3501    fn load_v2_version_data(
3502        &self,
3503        source: &SourceRecord,
3504        package_rowid: i64,
3505        package_hash: &str,
3506    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3507        let connection = Self::open_sqlite_connection(self.preindexed_index_path(source))
3508            .context("failed to reopen preindexed index for V2 version data")?;
3509        let package_hash = package_hash.to_ascii_lowercase();
3510        let package_id = query_optional_value(
3511            &connection,
3512            "SELECT id FROM packages WHERE rowid = ?1",
3513            vec![SqlValue::Integer(package_rowid)],
3514            |row| row_string(row, 0),
3515        )?
3516        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3517        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3518        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3519        let yaml = decompress_mszyml(&file.bytes)?;
3520        let document =
3521            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3522        Ok((document.versions, file.path))
3523    }
3524
3525    fn get_cached_source_file(
3526        &self,
3527        bucket: &str,
3528        source: &SourceRecord,
3529        relative_path: &str,
3530        expected_hash: Option<&str>,
3531    ) -> Result<CachedBytes> {
3532        let normalized_relative = relative_path.replace('\\', "/");
3533        let cache_path =
3534            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3535
3536        if cache_path.exists() {
3537            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3538            if hash_matches(expected_hash, &cached) {
3539                return Ok(CachedBytes {
3540                    path: cache_path,
3541                    bytes: cached,
3542                });
3543            }
3544        }
3545
3546        let url = format!(
3547            "{}/{}",
3548            source.arg.trim_end_matches('/'),
3549            normalized_relative.trim_start_matches('/')
3550        );
3551        let response = self
3552            .client
3553            .get(url)
3554            .send()
3555            .context("failed to fetch source file")?
3556            .error_for_status()
3557            .context("source file request returned an error")?;
3558        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3559
3560        if let Some(hash) = expected_hash {
3561            verify_hash(hash, &bytes)?;
3562        }
3563
3564        if let Some(parent) = cache_path.parent() {
3565            fs::create_dir_all(parent).context("failed to create cache directory")?;
3566        }
3567        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3568
3569        Ok(CachedBytes {
3570            path: cache_path,
3571            bytes,
3572        })
3573    }
3574
3575    fn get_or_fetch_rest_manifest(
3576        &mut self,
3577        source: &SourceRecord,
3578        package_id: &str,
3579        version: &str,
3580        channel: &str,
3581    ) -> Result<(Vec<u8>, PathBuf)> {
3582        let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel);
3583        if cache_path.exists() {
3584            return Ok((
3585                fs::read(&cache_path).context("failed to read cached REST manifest")?,
3586                cache_path,
3587            ));
3588        }
3589
3590        let info = self.load_rest_information_by_name(&source.name)?;
3591        let contract = choose_contract(&info.server_supported_versions)
3592            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3593        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3594
3595        let mut params = vec![("Version", version.to_owned())];
3596        if !channel.is_empty() {
3597            params.push(("Channel", channel.to_owned()));
3598        }
3599        if info
3600            .required_query_parameters
3601            .iter()
3602            .any(|value| value.eq_ignore_ascii_case("Market"))
3603        {
3604            params.push(("Market", default_market()));
3605        }
3606
3607        let response = self
3608            .client
3609            .get(url)
3610            .header("Version", contract)
3611            .query(&params)
3612            .send()
3613            .context("REST manifest request failed")?
3614            .error_for_status()
3615            .context("REST manifest request returned an error")?;
3616        let bytes = response
3617            .bytes()
3618            .context("failed to read REST manifest response")?
3619            .to_vec();
3620
3621        if let Some(parent) = cache_path.parent() {
3622            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3623        }
3624        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3625
3626        Ok((bytes, cache_path))
3627    }
3628
3629    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3630        let index = self
3631            .store
3632            .sources
3633            .iter()
3634            .position(|source| source.name.eq_ignore_ascii_case(name))
3635            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3636        self.load_rest_information(index)
3637    }
3638
3639    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3640        if let Some(name) = source_name {
3641            if let Some(index) = self
3642                .store
3643                .sources
3644                .iter()
3645                .position(|source| source.name.eq_ignore_ascii_case(name))
3646            {
3647                return Ok(vec![index]);
3648            }
3649
3650            let index = self
3651                .store
3652                .sources
3653                .iter()
3654                .position(|source| source.identifier.eq_ignore_ascii_case(name))
3655                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3656            return Ok(vec![index]);
3657        }
3658
3659        let mut indexes: Vec<_> = self
3660            .store
3661            .sources
3662            .iter()
3663            .enumerate()
3664            .filter(|(_, source)| !source.explicit)
3665            .collect();
3666        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3667            right_source
3668                .priority
3669                .cmp(&left_source.priority)
3670                .then_with(|| left_index.cmp(right_index))
3671        });
3672        Ok(indexes.into_iter().map(|(index, _)| index).collect())
3673    }
3674
3675    fn source_clone(&self, index: usize) -> SourceRecord {
3676        self.store.sources[index].clone()
3677    }
3678
3679    fn refresh_system_winget_sources(&mut self) -> Result<()> {
3680        self.store = match self.source_mode {
3681            EffectiveSourceMode::Private => self.store.clone(),
3682            EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?,
3683            EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?,
3684        };
3685        Ok(())
3686    }
3687
3688    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3689        self.refresh_system_winget_sources()?;
3690        let selected_names: Vec<_> = if source_name.is_none() {
3691            self.store.sources.iter().map(|source| source.name.clone()).collect()
3692        } else {
3693            self.resolve_source_indexes(source_name)?
3694                .into_iter()
3695                .map(|index| self.store.sources[index].name.clone())
3696                .collect()
3697        };
3698
3699        let detail = system_winget_update_sources(source_name)?;
3700        self.refresh_system_winget_sources()?;
3701
3702        Ok(selected_names
3703            .into_iter()
3704            .filter_map(|name| {
3705                self.store
3706                    .sources
3707                    .iter()
3708                    .find(|source| source.name.eq_ignore_ascii_case(&name))
3709                    .map(|source| SourceUpdateResult {
3710                        name: source.name.clone(),
3711                        kind: source.kind,
3712                        detail: detail.clone(),
3713                    })
3714            })
3715            .collect())
3716    }
3717}
3718
3719#[derive(Debug)]
3720struct CachedBytes {
3721    path: PathBuf,
3722    bytes: Vec<u8>,
3723}
3724
3725#[derive(Debug, Clone)]
3726struct V1SearchRow {
3727    manifest_rowid: i64,
3728    package_rowid: i64,
3729    version: String,
3730    channel: String,
3731    id: String,
3732    name: String,
3733    moniker: Option<String>,
3734    match_criteria: Option<String>,
3735}
3736
3737#[derive(Debug, Clone)]
3738struct V1VersionRow {
3739    version: String,
3740    channel: String,
3741    pathpart_id: i64,
3742    manifest_hash: Option<String>,
3743}
3744
3745#[derive(Debug, Clone)]
3746struct V2SearchRow {
3747    package_rowid: i64,
3748    package_hash: String,
3749    id: String,
3750    name: String,
3751    moniker: Option<String>,
3752    version: String,
3753    match_criteria: Option<String>,
3754}
3755
3756fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3757    query.query.is_some()
3758        || query.id.is_some()
3759        || query.name.is_some()
3760        || query.moniker.is_some()
3761        || query.tag.is_some()
3762        || query.command.is_some()
3763        || query.source.is_some()
3764}
3765
3766fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3767    PackageQuery {
3768        query: query.query.clone(),
3769        id: query.id.clone(),
3770        name: query.name.clone(),
3771        moniker: query.moniker.clone(),
3772        tag: query.tag.clone(),
3773        command: query.command.clone(),
3774        source: query.source.clone(),
3775        count: Some(LIST_LOOKUP_MAX_RESULTS),
3776        exact: query.exact,
3777        version: None,
3778        channel: None,
3779        locale: None,
3780        installer_type: None,
3781        installer_architecture: None,
3782        platform: None,
3783        os_version: None,
3784        install_scope: query.install_scope.clone(),
3785    }
3786}
3787
3788fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3789    query.query.is_some() || query.id.is_some() || query.name.is_some()
3790}
3791
3792fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3793    if is_greater_than_version_marker(&package.installed_version) {
3794        return false;
3795    }
3796    package
3797        .correlated
3798        .as_ref()
3799        .and_then(|candidate| candidate.version.as_deref())
3800        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3801}
3802
3803fn is_greater_than_version_marker(version: &str) -> bool {
3804    version.trim_start().starts_with('>')
3805}
3806
3807fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3808    package.installed_version.eq_ignore_ascii_case("Unknown")
3809}
3810
3811fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3812    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
3813    // Discord). Explicit `--id` filters surface them regardless.
3814    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3815        return false;
3816    }
3817    // Hide rows whose latest catalog version has no installer the host can
3818    // run — winget refuses to upgrade past an unusable architecture.
3819    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3820        return false;
3821    }
3822    installed_package_has_upgrade(package)
3823        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3824}
3825
3826/// Collapses installed entries that correlate to the same catalog package id
3827/// (case-insensitive within a source) down to a single representative — the
3828/// one with the highest installed version. Uncorrelated entries pass through
3829/// untouched; they can't appear in `upgrade` output anyway (the upgrade
3830/// filter requires a correlation), so they're effectively a no-op here.
3831fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3832    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3833    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3834
3835    for package in packages {
3836        match package.correlated.as_ref() {
3837            Some(candidate) => {
3838                let key = (
3839                    candidate.id.to_ascii_lowercase(),
3840                    candidate.source_name.to_ascii_lowercase(),
3841                );
3842                let keep_new = match by_id.get(&key) {
3843                    Some(existing) => {
3844                        // Prefer rows whose `installed_version` was remapped to
3845                        // a catalog Version (via aMiV/aMaV). Comparing a raw
3846                        // ARP DisplayVersion like `40.10.18029` against the
3847                        // catalog's `10.0.300` is meaningless and would also
3848                        // hide the row that legitimately upgrades to it.
3849                        match (
3850                            package.installed_version_canonical,
3851                            existing.installed_version_canonical,
3852                        ) {
3853                            (true, false) => true,
3854                            (false, true) => false,
3855                            _ => {
3856                                compare_version(&package.installed_version, &existing.installed_version)
3857                                    == Ordering::Greater
3858                            }
3859                        }
3860                    }
3861                    None => true,
3862                };
3863                if keep_new {
3864                    by_id.insert(key, package);
3865                }
3866            }
3867            None => uncorrelated.push(package),
3868        }
3869    }
3870
3871    let mut result = uncorrelated;
3872    result.extend(by_id.into_values());
3873    result
3874}
3875
3876fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3877    let mut source_specific = None;
3878    let mut source_agnostic = None;
3879
3880    for pin in pins {
3881        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3882            continue;
3883        }
3884
3885        if pin.source_id.is_empty() {
3886            if source_agnostic.is_none() {
3887                source_agnostic = Some(pin);
3888            }
3889        } else if item
3890            .source_name
3891            .as_deref()
3892            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3893        {
3894            source_specific = Some(pin);
3895            break;
3896        }
3897    }
3898
3899    source_specific.or(source_agnostic)
3900}
3901
3902fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3903    let Some(available_version) = item.available_version.as_deref() else {
3904        return false;
3905    };
3906    let Some(pin) = find_applicable_pin(item, pins) else {
3907        return false;
3908    };
3909
3910    match pin.pin_type {
3911        PinType::Blocking => true,
3912        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3913    }
3914}
3915
3916fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3917    if pattern.trim().is_empty() || pattern == "*" {
3918        return true;
3919    }
3920
3921    if !pattern.contains('*') {
3922        return version.eq_ignore_ascii_case(pattern);
3923    }
3924
3925    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3926        let prefix = &pattern[..pattern.len() - 1];
3927        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3928    }
3929
3930    version.eq_ignore_ascii_case(pattern)
3931}
3932
3933fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch {
3934    let available_version = package.correlated.as_ref().and_then(|candidate| {
3935        candidate.version.as_ref().and_then(|candidate_version| {
3936            if !is_greater_than_version_marker(&package.installed_version)
3937                && (installed_package_has_unknown_version(&package)
3938                    || compare_version(candidate_version, &package.installed_version) == Ordering::Greater)
3939            {
3940                Some(candidate_version.clone())
3941            } else {
3942                None
3943            }
3944        })
3945    });
3946    let mut source_name = package
3947        .correlated
3948        .as_ref()
3949        .map(|candidate| candidate.source_name.clone())
3950        .filter(|value| !value.is_empty());
3951    let mut id = package
3952        .correlated
3953        .as_ref()
3954        .map(|candidate| candidate.id.clone())
3955        .unwrap_or_else(|| package.local_id.clone());
3956
3957    if source_name.is_none()
3958        && let Some(package_id) = package
3959            .winget_package_identifier
3960            .as_deref()
3961            .filter(|value| !value.trim().is_empty())
3962    {
3963        id = package_id.to_owned();
3964        source_name = package
3965            .winget_source_identifier
3966            .as_deref()
3967            .filter(|value| !value.trim().is_empty())
3968            .map(|identifier| source_name_from_identifier(identifier, sources));
3969    }
3970
3971    if source_name.is_none()
3972        && let Some((local_package_id, local_source_name)) =
3973            winget_package_identity_from_local_id(&package.local_id, sources)
3974    {
3975        id = local_package_id;
3976        source_name = Some(local_source_name);
3977    }
3978
3979    ListMatch {
3980        name: package.name,
3981        id,
3982        local_id: package.local_id,
3983        installed_version: package.installed_version,
3984        available_version,
3985        source_name,
3986        publisher: package.publisher,
3987        scope: package.scope,
3988        installer_category: package.installer_category,
3989        install_location: package.install_location,
3990        package_family_names: package.package_family_names,
3991        product_codes: package.product_codes,
3992        upgrade_codes: package.upgrade_codes,
3993    }
3994}
3995
3996fn suppress_duplicate_available_versions(matches: Vec<ListMatch>) -> Vec<ListMatch> {
3997    let mut grouped: HashMap<(String, String), Vec<usize>> = HashMap::new();
3998    for (index, item) in matches.iter().enumerate() {
3999        let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else {
4000            continue;
4001        };
4002        grouped
4003            .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4004            .or_default()
4005            .push(index);
4006    }
4007
4008    let mut keep = BTreeSet::new();
4009    for indexes in grouped.values().filter(|indexes| indexes.len() > 1) {
4010        let Some(best) = indexes
4011            .iter()
4012            .map(|index| matches[*index].installed_version.as_str())
4013            .max_by(|left, right| compare_version(left, right))
4014        else {
4015            continue;
4016        };
4017        for index in indexes {
4018            if compare_version(&matches[*index].installed_version, best) == Ordering::Equal {
4019                keep.insert(*index);
4020            }
4021        }
4022    }
4023
4024    matches
4025        .into_iter()
4026        .enumerate()
4027        .map(|(index, mut item)| {
4028            if item.available_version.is_some() && !keep.contains(&index) {
4029                let is_duplicate = item
4030                    .source_name
4031                    .as_ref()
4032                    .map(|source_name| {
4033                        grouped
4034                            .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4035                            .is_some_and(|indexes| indexes.len() > 1)
4036                    })
4037                    .unwrap_or(false);
4038                if is_duplicate {
4039                    item.available_version = None;
4040                }
4041            }
4042            item
4043        })
4044        .collect()
4045}
4046
4047fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String {
4048    sources
4049        .iter()
4050        .find(|source| source.identifier.eq_ignore_ascii_case(identifier))
4051        .map(|source| source.name.clone())
4052        .unwrap_or_else(|| identifier.to_owned())
4053}
4054
4055fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> {
4056    let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1);
4057    for source in sources {
4058        let source_suffix = format!("_{}", source.identifier);
4059        let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else {
4060            continue;
4061        };
4062        if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) {
4063            continue;
4064        }
4065        if source_identifier_start_index <= package_id_start_index {
4066            return None;
4067        }
4068        let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned();
4069        if package_id.trim().is_empty() {
4070            return None;
4071        }
4072        return Some((package_id, source.name.clone()));
4073    }
4074    None
4075}
4076
4077fn list_sort_weight(package: &InstalledPackage) -> usize {
4078    if package.local_id.starts_with("ARP\\") {
4079        0
4080    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
4081        1
4082    } else {
4083        2
4084    }
4085}
4086
4087fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
4088    let correlated = package.correlated.as_ref();
4089
4090    if let Some(value) = &query.id {
4091        let local_match = matches_text(&package.local_id, value, query.exact);
4092        let correlated_match = correlated
4093            .map(|candidate| matches_text(&candidate.id, value, query.exact))
4094            .unwrap_or(false);
4095        if !local_match && !correlated_match {
4096            return false;
4097        }
4098    }
4099
4100    if let Some(value) = &query.name
4101        && !matches_text(&package.name, value, query.exact)
4102    {
4103        return false;
4104    }
4105
4106    if let Some(value) = &query.product_code
4107        && !package
4108            .product_codes
4109            .iter()
4110            .any(|code| code.eq_ignore_ascii_case(value))
4111    {
4112        return false;
4113    }
4114
4115    if let Some(value) = &query.version
4116        && !package.installed_version.eq_ignore_ascii_case(value)
4117    {
4118        return false;
4119    }
4120
4121    if let Some(value) = &query.query {
4122        let local_match =
4123            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
4124        let correlated_match = correlated
4125            .map(|candidate| {
4126                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
4127            })
4128            .unwrap_or(false);
4129        if !local_match && !correlated_match {
4130            return false;
4131        }
4132    }
4133
4134    if let Some(source) = &query.source
4135        && correlated
4136            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
4137            .unwrap_or(true)
4138    {
4139        return false;
4140    }
4141
4142    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
4143        return false;
4144    }
4145
4146    true
4147}
4148
4149fn correlate_installed_package(
4150    package: &InstalledPackage,
4151    candidates: &[SearchMatch],
4152    allow_loose_name_match: bool,
4153) -> Option<SearchMatch> {
4154    // MSIX entries can only be correlated through their PackageFamilyName.
4155    // That lookup happens earlier in `correlate_installed_via_index`; if it
4156    // didn't find a match, name-based fallback is wrong — the catalog
4157    // doesn't carry this MSIX's identity, so any name collision (e.g. the
4158    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
4159    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
4160    // doesn't make.
4161    if package.local_id.starts_with("MSIX\\") {
4162        return None;
4163    }
4164
4165    let installed_name = normalize_correlation_name(&package.name);
4166    let installed_name_lower = package.name.to_ascii_lowercase();
4167    let candidate_names = correlation_name_candidates(&package.name);
4168
4169    let mut scored: Vec<(i32, SearchMatch)> = candidates
4170        .iter()
4171        .filter_map(|candidate| {
4172            let candidate_name = normalize_correlation_name(&candidate.name);
4173            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
4174                1000
4175            } else if candidate_names.iter().any(|name| {
4176                let normalized = normalize_correlation_name(name);
4177                normalized == candidate_name
4178            }) {
4179                900
4180            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
4181                700
4182            } else {
4183                0
4184            };
4185
4186            if base_score == 0 {
4187                return None;
4188            }
4189
4190            // Tiebreaker so we pick the *right* candidate when multiple catalog
4191            // entries collapse to the same normalized name or all loose-match a
4192            // long installed name. Two failure modes this addresses:
4193            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
4194            //     alphanumeric filter strips `+`/`-`), so both score 900 and
4195            //     `max_by_key` arbitrarily picked the latter.
4196            //   • Loose substring match picks up `Studio` inside
4197            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
4198            //     `Microsoft.DotNet.SDK.10` when both score 700.
4199            // The bonus rewards catalog names that appear verbatim in the
4200            // installed display name — strongly when they anchor the start,
4201            // weaker when they're embedded.
4202            let candidate_lower = candidate.name.to_ascii_lowercase();
4203            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
4204                100
4205            } else if installed_name_lower.contains(&candidate_lower) {
4206                50
4207            } else {
4208                0
4209            };
4210
4211            Some((base_score + prefix_bonus, candidate.clone()))
4212        })
4213        .collect();
4214
4215    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
4216    let mut iter = scored.into_iter();
4217    let (top_score, top_match) = iter.next()?;
4218
4219    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
4220    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
4221    // normalize their names to `git` and tie at the same score — the only
4222    // signal that disambiguates them, publisher, isn't part of this scoring
4223    // function. winget refuses the correlation in this case (the install
4224    // shows up with an empty Source); we do the same to avoid manufacturing
4225    // a fake upgrade against the wrong package.
4226    if let Some((next_score, _)) = iter.next()
4227        && next_score == top_score
4228    {
4229        return None;
4230    }
4231    Some(top_match)
4232}
4233
4234fn correlation_name_candidates(name: &str) -> Vec<String> {
4235    let mut candidates = Vec::new();
4236    candidates.push(name.trim().to_owned());
4237
4238    let mut trimmed = name.trim().to_owned();
4239    if let Some(index) = trimmed.find(" (") {
4240        trimmed.truncate(index);
4241    }
4242
4243    let mut words = Vec::new();
4244    for token in trimmed.split_whitespace() {
4245        let lower = token
4246            .trim_matches(|ch: char| ch == '(' || ch == ')')
4247            .to_ascii_lowercase();
4248        if !words.is_empty()
4249            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
4250        {
4251            break;
4252        }
4253        words.push(token);
4254    }
4255
4256    if !words.is_empty() {
4257        candidates.push(words.join(" "));
4258    }
4259
4260    candidates.sort();
4261    candidates.dedup();
4262    candidates
4263}
4264
4265fn normalize_correlation_name(value: &str) -> String {
4266    value
4267        .chars()
4268        .filter(|ch| ch.is_ascii_alphanumeric())
4269        .flat_map(|ch| ch.to_lowercase())
4270        .collect()
4271}
4272
4273struct V2PackageMetadata {
4274    rowid: i64,
4275    id: String,
4276    name: String,
4277    moniker: Option<String>,
4278    latest_version: String,
4279    package_hash: String,
4280}
4281
4282/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
4283/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
4284/// and the field that produced the match. This is winget's primary correlation
4285/// path; falling back to display-name matching is only correct for ARP entries
4286/// that lack all three identifiers.
4287fn lookup_identity_match_v2(
4288    connection: &Connection,
4289    package: &InstalledPackage,
4290) -> Result<Option<(i64, &'static str)>> {
4291    for pfn in &package.package_family_names {
4292        if let Some(rowid) = query_optional_value(
4293            connection,
4294            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
4295            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
4296            |row| row_i64(row, 0),
4297        )? {
4298            return Ok(Some((rowid, "PackageFamilyName")));
4299        }
4300    }
4301    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
4302    // identity — it stays stable across versions, while ProductCode changes
4303    // every release. When a single ProductCode happens to also be listed
4304    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
4305    // installer rows from earlier LTS-line ProductCodes, while the v24
4306    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
4307    // the upgrade-side match represents the user's "package family" — the
4308    // one winget uses.
4309    for code in &package.upgrade_codes {
4310        if let Some(rowid) = query_optional_value(
4311            connection,
4312            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
4313            vec![SqlValue::Text(code.to_ascii_lowercase())],
4314            |row| row_i64(row, 0),
4315        )? {
4316            return Ok(Some((rowid, "UpgradeCode")));
4317        }
4318    }
4319    for code in &package.product_codes {
4320        if let Some(rowid) = query_optional_value(
4321            connection,
4322            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
4323            vec![SqlValue::Text(code.to_ascii_lowercase())],
4324            |row| row_i64(row, 0),
4325        )? {
4326            return Ok(Some((rowid, "ProductCode")));
4327        }
4328    }
4329    Ok(None)
4330}
4331
4332fn installed_db_identity_tables_present(connection: &Connection) -> Result<bool> {
4333    Ok(table_exists(connection, "manifest")?
4334        && table_exists(connection, "ids")?
4335        && table_exists(connection, "names")?
4336        && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?)
4337            || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?)
4338            || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?)))
4339}
4340
4341fn table_exists(connection: &Connection, name: &str) -> Result<bool> {
4342    query_optional_value(
4343        connection,
4344        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
4345        vec![SqlValue::Text(name.to_owned())],
4346        |row| row_i64(row, 0),
4347    )
4348    .map(|value| value.is_some())
4349}
4350
4351fn lookup_installed_db_identity_match(
4352    connection: &Connection,
4353    package: &InstalledPackage,
4354) -> Result<Option<(String, String, &'static str)>> {
4355    for pfn in &package.package_family_names {
4356        if let Some(hit) =
4357            lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")?
4358        {
4359            return Ok(Some(hit));
4360        }
4361    }
4362    for code in &package.upgrade_codes {
4363        if let Some(hit) = lookup_installed_db_identity_match_value(
4364            connection,
4365            "upgradecodes",
4366            "upgradecodes_map",
4367            "upgradecode",
4368            code,
4369            "UpgradeCode",
4370        )? {
4371            return Ok(Some(hit));
4372        }
4373    }
4374    for code in &package.product_codes {
4375        if let Some(hit) = lookup_installed_db_identity_match_value(
4376            connection,
4377            "productcodes",
4378            "productcodes_map",
4379            "productcode",
4380            code,
4381            "ProductCode",
4382        )? {
4383            return Ok(Some(hit));
4384        }
4385    }
4386    Ok(None)
4387}
4388
4389fn lookup_installed_db_identity_match_value(
4390    connection: &Connection,
4391    value_table: &'static str,
4392    map_table: &'static str,
4393    value_column: &'static str,
4394    value: &str,
4395    matched_by: &'static str,
4396) -> Result<Option<(String, String, &'static str)>> {
4397    let sql = format!(
4398        "SELECT ids.id, names.name \
4399         FROM {map_table} map \
4400         JOIN {value_table} value ON map.{value_column} = value.rowid \
4401         JOIN manifest ON map.manifest = manifest.rowid \
4402         JOIN ids ON manifest.id = ids.rowid \
4403         JOIN names ON manifest.name = names.rowid \
4404         WHERE value.{value_column} = ?1 \
4405         LIMIT 1"
4406    );
4407    query_optional_value(
4408        connection,
4409        &sql,
4410        vec![SqlValue::Text(value.to_ascii_lowercase())],
4411        |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)),
4412    )
4413}
4414
4415/// Resolves a v2 package by its catalog id back to its rowid + hash so the
4416/// post-correlation enrichment pass can load versionData for rows that
4417/// correlated through the name-based fallback (which throws the rowid away).
4418fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
4419    query_optional_value(
4420        connection,
4421        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
4422        vec![SqlValue::Text(package_id.to_owned())],
4423        |row| {
4424            Ok(V2PackageMetadata {
4425                rowid: row_i64(row, 0)?,
4426                id: row_string(row, 1)?,
4427                name: row_string(row, 2)?,
4428                moniker: row_opt_string(row, 3)?,
4429                latest_version: row_string(row, 4)?,
4430                package_hash: row_hex_string(row, 5)?,
4431            })
4432        },
4433    )
4434}
4435
4436/// MSIX entries whose Windows resource-string display name (e.g.
4437/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
4438/// would otherwise leak that placeholder into pinget's output. winget
4439/// resolves it via `Windows.Management.Deployment` and shows the catalog's
4440/// `PackageName`; we mirror that by substituting the correlated catalog
4441/// name once the package is correlated, which is the parity-faithful
4442/// substitute we can produce without taking a WinRT dependency.
4443fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
4444    if !package.local_id.starts_with("MSIX\\") {
4445        return;
4446    }
4447    if !package.name.starts_with("ms-resource:") {
4448        return;
4449    }
4450    let Some(candidate) = package.correlated.as_ref() else {
4451        return;
4452    };
4453    if !candidate.name.is_empty() {
4454        package.name = candidate.name.clone();
4455    }
4456}
4457
4458fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
4459    query_optional_value(
4460        connection,
4461        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
4462        vec![SqlValue::Integer(package_rowid)],
4463        |row| {
4464            Ok(V2PackageMetadata {
4465                rowid: package_rowid,
4466                id: row_string(row, 0)?,
4467                name: row_string(row, 1)?,
4468                moniker: row_opt_string(row, 2)?,
4469                latest_version: row_string(row, 3)?,
4470                package_hash: row_hex_string(row, 4)?,
4471            })
4472        },
4473    )
4474}
4475
4476fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
4477    query_optional_value(
4478        connection,
4479        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
4480        Vec::new(),
4481        |row| row_i64(row, 0),
4482    )
4483    .ok()
4484    .flatten()
4485    .is_some()
4486}
4487
4488/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
4489/// pair matches the installed package — if and only if the match is
4490/// unique. Multiple matches mean the normalization wasn't strong enough to
4491/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
4492/// distinct catalog packages); winget refuses to correlate in that case
4493/// and we do the same.
4494fn lookup_unique_normalized_identity(
4495    connection: &Connection,
4496    norm_name: &str,
4497    norm_publisher: &str,
4498) -> Result<Option<i64>> {
4499    let rows: Vec<i64> = query_rows(
4500        connection,
4501        "SELECT DISTINCT n.package FROM norm_names2 n \
4502         WHERE n.norm_name = ?1 \
4503         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
4504         LIMIT 2",
4505        vec![
4506            SqlValue::Text(norm_name.to_owned()),
4507            SqlValue::Text(norm_publisher.to_owned()),
4508        ],
4509        |row| row_i64(row, 0),
4510    )?;
4511    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
4512}
4513
4514fn v2_identity_tables_present(connection: &Connection) -> bool {
4515    query_optional_value(
4516        connection,
4517        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
4518        Vec::new(),
4519        |row| row_i64(row, 0),
4520    )
4521    .ok()
4522    .flatten()
4523    .is_some()
4524}
4525
4526/// Translates an ARP DisplayVersion to the catalog Version it maps to.
4527/// For packages like the .NET SDK or Python the ARP version is an MSI build
4528/// number (e.g. `10.1.826.23019`) unrelated to the public version
4529/// (`10.0.108`). winget records the ARP range per catalog version in
4530/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
4531/// contains the installed version.
4532fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4533    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4534        return None;
4535    }
4536    for entry in entries {
4537        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
4538            continue;
4539        };
4540        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
4541        {
4542            return Some(entry.version.clone());
4543        }
4544    }
4545    None
4546}
4547
4548/// Returns `< latest>` when ARP range metadata proves the installed version is
4549/// below the newest catalog version, but the raw DisplayVersion is too coarse
4550/// to map into a concrete aMiV/aMaV bucket. Snagit 2025 reports `25.4.1` while
4551/// the catalog's latest ARP build is `25.4.1.10325`; winget renders that as
4552/// installed `< 25.4.1` with available `25.4.1`.
4553fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4554    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4555        return None;
4556    }
4557
4558    let latest = entries
4559        .iter()
4560        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4561        .max_by(|a, b| compare_version(&a.version, &b.version))?;
4562    if compare_version(arp_version, &latest.version) != Ordering::Equal {
4563        return None;
4564    }
4565    let latest_min = latest.arp_min_version.as_deref()?;
4566    if compare_version(arp_version, latest_min) != Ordering::Less {
4567        return None;
4568    }
4569
4570    let previous_max = entries
4571        .iter()
4572        .filter(|entry| compare_version(&entry.version, &latest.version) == Ordering::Less)
4573        .filter_map(|entry| entry.arp_max_version.as_deref())
4574        .max_by(|left, right| compare_version(left, right));
4575    if previous_max.is_some_and(|max| compare_version(arp_version, max) != Ordering::Greater) {
4576        return None;
4577    }
4578
4579    Some(format!("< {}", latest.version))
4580}
4581
4582/// Returns `> latest` when ARP range metadata proves the installed package
4583/// version is newer than the newest catalog version that has ARP bounds. This
4584/// happens for inbox MSIX frameworks that Windows services beyond winget's
4585/// latest user-facing catalog mapping.
4586fn greater_than_latest_arp_anchored_version(
4587    entries: &[PackageVersionDataEntry],
4588    arp_version: &str,
4589    catalog_latest_version: &str,
4590) -> Option<String> {
4591    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4592        return None;
4593    }
4594
4595    let latest = entries
4596        .iter()
4597        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4598        .max_by(|a, b| compare_version(&a.version, &b.version))?;
4599    let latest_max = latest.arp_max_version.as_deref()?;
4600    if !latest.version.eq_ignore_ascii_case(catalog_latest_version) {
4601        return None;
4602    }
4603    if !has_different_leading_version_number(latest_max, &latest.version) {
4604        return None;
4605    }
4606    if compare_version(arp_version, latest_max) != Ordering::Greater {
4607        return None;
4608    }
4609
4610    Some(format!("> {}", latest.version))
4611}
4612
4613fn has_different_leading_version_number(left: &str, right: &str) -> bool {
4614    let Some(left) = leading_version_number(left) else {
4615        return false;
4616    };
4617    let Some(right) = leading_version_number(right) else {
4618        return false;
4619    };
4620    !left.eq_ignore_ascii_case(right)
4621}
4622
4623fn leading_version_number(value: &str) -> Option<&str> {
4624    let trimmed = value.trim_start();
4625    let length = trimmed
4626        .chars()
4627        .take_while(|ch| ch.is_ascii_digit())
4628        .map(char::len_utf8)
4629        .sum();
4630    (length > 0).then_some(&trimmed[..length])
4631}
4632
4633/// Returns the latest catalog Version that carries ARP-range metadata
4634/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
4635/// publish "internal" version rows whose `v` is an MSI build number
4636/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
4637/// upgrade targets — when the installed side was matched through an ARP
4638/// range, the available side must be a peer that also exposes ARP bounds so
4639/// the two versions compare on the same scale. Returns `None` when no entry
4640/// has bounds; the caller should fall back to `packages.latest_version`.
4641fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
4642    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        .map(|entry| entry.version.clone())
4647}
4648
4649#[cfg(windows)]
4650fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4651    let mut packages = Vec::new();
4652    let mut seen = BTreeSet::new();
4653
4654    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
4655    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
4656
4657    // MSI registers UpgradeCode → ProductCode mappings under
4658    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
4659    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
4660    // entries don't expose `UpgradeCode` directly, so winget reads it from
4661    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
4662    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
4663    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
4664    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
4665
4666    if machine {
4667        collect_uninstall_view(
4668            &mut packages,
4669            &mut seen,
4670            &upgrade_codes,
4671            RegKey::predef(HKEY_LOCAL_MACHINE),
4672            "Machine",
4673            "X64",
4674            KEY_READ | KEY_WOW64_64KEY,
4675        )?;
4676        collect_uninstall_view(
4677            &mut packages,
4678            &mut seen,
4679            &upgrade_codes,
4680            RegKey::predef(HKEY_LOCAL_MACHINE),
4681            "Machine",
4682            "X86",
4683            KEY_READ | KEY_WOW64_32KEY,
4684        )?;
4685        collect_appmodel_packages(
4686            &mut packages,
4687            &mut seen,
4688            RegKey::predef(HKEY_LOCAL_MACHINE),
4689            "Machine",
4690            KEY_READ | KEY_WOW64_64KEY,
4691        )?;
4692    }
4693
4694    if user {
4695        collect_uninstall_view(
4696            &mut packages,
4697            &mut seen,
4698            &upgrade_codes,
4699            RegKey::predef(HKEY_CURRENT_USER),
4700            "User",
4701            "X64",
4702            KEY_READ,
4703        )?;
4704        collect_appmodel_packages(
4705            &mut packages,
4706            &mut seen,
4707            RegKey::predef(HKEY_CURRENT_USER),
4708            "User",
4709            KEY_READ,
4710        )?;
4711    }
4712
4713    Ok(packages)
4714}
4715
4716/// Builds a `ProductCode → UpgradeCode` map (both in standard
4717/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
4718/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
4719/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
4720/// flipped ProductCodes registered under that UpgradeCode.
4721#[cfg(windows)]
4722fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4723    let mut map = HashMap::new();
4724    if include_machine {
4725        collect_msi_upgrade_codes_from(
4726            RegKey::predef(HKEY_LOCAL_MACHINE),
4727            r"SOFTWARE\Classes\Installer\UpgradeCodes",
4728            KEY_READ | KEY_WOW64_64KEY,
4729            &mut map,
4730        );
4731    }
4732    if include_user {
4733        collect_msi_upgrade_codes_from(
4734            RegKey::predef(HKEY_CURRENT_USER),
4735            r"Software\Microsoft\Installer\UpgradeCodes",
4736            KEY_READ,
4737            &mut map,
4738        );
4739    }
4740    map
4741}
4742
4743#[cfg(windows)]
4744fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4745    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4746        return;
4747    };
4748    for key_name in upgrade_codes.enum_keys().flatten() {
4749        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4750            continue;
4751        };
4752        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4753            continue;
4754        };
4755        for (value_name, _) in subkey.enum_values().flatten() {
4756            if let Some(product_code) = unflip_packed_guid(&value_name) {
4757                // ARP-side keys are likely to win the latest insert; first one
4758                // is fine — we just need *any* mapping. Use `entry` so the
4759                // first writer per ProductCode wins to stay deterministic.
4760                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4761            }
4762        }
4763    }
4764}
4765
4766/// Converts the 32-character "packed GUID" used inside the MSI Installer
4767/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
4768/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
4769/// then eight 2-char byte pairs) of the GUID's hex representation.
4770#[cfg(windows)]
4771fn unflip_packed_guid(packed: &str) -> Option<String> {
4772    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4773        return None;
4774    }
4775    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4776    let bytes = packed.as_bytes();
4777    let mut reversed = String::with_capacity(32);
4778    let mut offset = 0;
4779    for size in CHUNKS {
4780        let chunk = &bytes[offset..offset + size];
4781        for &c in chunk.iter().rev() {
4782            reversed.push(c as char);
4783        }
4784        offset += size;
4785    }
4786    let r = reversed.to_ascii_lowercase();
4787    Some(format!(
4788        "{{{}-{}-{}-{}-{}}}",
4789        &r[0..8],
4790        &r[8..12],
4791        &r[12..16],
4792        &r[16..20],
4793        &r[20..32]
4794    ))
4795}
4796
4797#[cfg(not(windows))]
4798fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4799    Ok(Vec::new())
4800}
4801
4802#[cfg(windows)]
4803fn collect_uninstall_view(
4804    packages: &mut Vec<InstalledPackage>,
4805    seen: &mut BTreeSet<String>,
4806    upgrade_code_map: &HashMap<String, String>,
4807    root: RegKey,
4808    scope: &str,
4809    arch: &str,
4810    flags: u32,
4811) -> Result<()> {
4812    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4813
4814    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4815        Ok(key) => key,
4816        Err(_) => return Ok(()),
4817    };
4818
4819    for key_name in uninstall.enum_keys().flatten() {
4820        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4821            Ok(key) => key,
4822            Err(_) => continue,
4823        };
4824        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4825        {
4826            continue;
4827        }
4828
4829        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4830            continue;
4831        };
4832
4833        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4834        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4835        let publisher = read_reg_string(&subkey, "Publisher");
4836        let install_location = read_reg_string(&subkey, "InstallLocation");
4837        let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier");
4838        let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier");
4839        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4840            .into_iter()
4841            .collect::<Vec<_>>();
4842        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4843        if !product_codes.iter().any(|code| code.eq_ignore_ascii_case(&key_name)) {
4844            product_codes.push(key_name.to_ascii_lowercase());
4845        }
4846        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4847        if upgrade_codes.is_empty() {
4848            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
4849            // Installer registry hive so identity correlation can fall back to
4850            // upgradecodes2 when productcodes2 picks the wrong sibling.
4851            for code in &product_codes {
4852                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4853                    upgrade_codes.push(uc.clone());
4854                    break;
4855                }
4856            }
4857        }
4858        let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4859            .as_deref()
4860            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4861        {
4862            // Honor the WinGet ARP signal so uninstall flows through
4863            // uninstall_portable (clean dir + registry removal) instead of
4864            // delegating the WinGet portable UninstallString to a winget
4865            // subprocess that only fully cleans up entries winget itself
4866            // installed.
4867            Some("portable".to_owned())
4868        } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4869            Some("msi".to_owned())
4870        } else if key_name.starts_with("MSIX\\") {
4871            Some("msix".to_owned())
4872        } else {
4873            Some("exe".to_owned())
4874        };
4875
4876        let dedupe_key = format!(
4877            "{}|{}|{}|{}",
4878            local_id,
4879            name.to_ascii_lowercase(),
4880            installed_version.to_ascii_lowercase(),
4881            publisher.clone().unwrap_or_default().to_ascii_lowercase()
4882        );
4883        if !seen.insert(dedupe_key) {
4884            continue;
4885        }
4886
4887        packages.push(InstalledPackage {
4888            name,
4889            local_id,
4890            installed_version,
4891            publisher,
4892            scope: Some(scope.to_owned()),
4893            installer_category,
4894            install_location,
4895            winget_package_identifier,
4896            winget_source_identifier,
4897            package_family_names,
4898            product_codes,
4899            upgrade_codes,
4900            correlated: None,
4901            installed_version_canonical: false,
4902            correlated_requires_explicit_upgrade: false,
4903            correlated_lacks_compatible_installer: false,
4904        });
4905    }
4906
4907    Ok(())
4908}
4909
4910#[cfg(windows)]
4911fn collect_appmodel_packages(
4912    packages: &mut Vec<InstalledPackage>,
4913    seen: &mut BTreeSet<String>,
4914    root: RegKey,
4915    scope: &str,
4916    flags: u32,
4917) -> Result<()> {
4918    const APPMODEL_PACKAGES_PATH: &str =
4919        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4920
4921    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4922        Ok(key) => key,
4923        Err(_) => return Ok(()),
4924    };
4925
4926    for key_name in appmodel.enum_keys().flatten() {
4927        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4928            Ok(key) => key,
4929            Err(_) => continue,
4930        };
4931
4932        let install_location = read_reg_string(&subkey, "PackageRootFolder");
4933        if install_location.as_deref().is_some_and(is_windows_system_path) {
4934            continue;
4935        }
4936
4937        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4938            continue;
4939        };
4940        if is_msix_split_resource_package(&metadata.resource_id) {
4941            continue;
4942        }
4943
4944        let Some(name) = read_reg_string(&subkey, "DisplayName")
4945            .or_else(|| read_appmodel_display_name(&subkey))
4946            .filter(|value| !value.is_empty())
4947        else {
4948            continue;
4949        };
4950
4951        let local_id = format!(r"MSIX\{key_name}");
4952        let dedupe_key = format!(
4953            "{}|{}|{}",
4954            local_id,
4955            name.to_ascii_lowercase(),
4956            metadata.version.to_ascii_lowercase()
4957        );
4958        if !seen.insert(dedupe_key) {
4959            continue;
4960        }
4961
4962        packages.push(InstalledPackage {
4963            name,
4964            local_id,
4965            installed_version: metadata.version,
4966            publisher: None,
4967            scope: Some(scope.to_owned()),
4968            installer_category: Some("msix".to_owned()),
4969            install_location,
4970            winget_package_identifier: None,
4971            winget_source_identifier: None,
4972            package_family_names: vec![metadata.family_name],
4973            product_codes: Vec::new(),
4974            upgrade_codes: Vec::new(),
4975            correlated: None,
4976            installed_version_canonical: false,
4977            correlated_requires_explicit_upgrade: false,
4978            correlated_lacks_compatible_installer: false,
4979        });
4980    }
4981
4982    Ok(())
4983}
4984
4985#[cfg(windows)]
4986struct ParsedMsixPackageFullName {
4987    version: String,
4988    resource_id: String,
4989    family_name: String,
4990}
4991
4992#[cfg(windows)]
4993fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4994    let segments = value.split('_').collect::<Vec<_>>();
4995    if segments.len() < 5 {
4996        return None;
4997    }
4998
4999    let name = segments[..segments.len() - 4].join("_");
5000    if name.is_empty() {
5001        return None;
5002    }
5003
5004    let version = segments[segments.len() - 4].trim();
5005    let resource_id = segments[segments.len() - 2].trim();
5006    let publisher_id = segments[segments.len() - 1].trim();
5007    if version.is_empty() || publisher_id.is_empty() {
5008        return None;
5009    }
5010
5011    let family_name = if resource_id.is_empty() {
5012        format!("{name}_{publisher_id}")
5013    } else {
5014        format!("{name}_{resource_id}_{publisher_id}")
5015    };
5016
5017    Some(ParsedMsixPackageFullName {
5018        version: version.to_owned(),
5019        resource_id: resource_id.to_owned(),
5020        family_name,
5021    })
5022}
5023
5024#[cfg(windows)]
5025fn read_appmodel_display_name(package_key: &RegKey) -> Option<String> {
5026    let app_key = package_key.open_subkey(r"App\Capabilities").ok()?;
5027    read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName"))
5028}
5029
5030#[cfg(windows)]
5031fn is_msix_split_resource_package(resource_id: &str) -> bool {
5032    resource_id.trim().to_ascii_lowercase().starts_with("split.")
5033}
5034
5035#[cfg(windows)]
5036fn is_windows_system_path(path: &str) -> bool {
5037    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
5038}
5039
5040#[cfg(windows)]
5041fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
5042    key.get_value::<String, _>(value_name)
5043        .ok()
5044        .map(|value| value.trim().to_owned())
5045        .filter(|value| !value.is_empty())
5046}
5047
5048#[cfg(windows)]
5049fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
5050    key.get_value::<u32, _>(value_name).ok()
5051}
5052
5053fn ensure_app_dirs(app_root: &Path) -> Result<()> {
5054    fs::create_dir_all(app_root).context("failed to create app root directory")?;
5055    if uses_packaged_layout(app_root) {
5056        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
5057        let user_sources_path = packaged_user_sources_path(app_root)?;
5058        if let Some(parent) = user_sources_path.parent() {
5059            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
5060        }
5061    } else {
5062        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
5063    }
5064    Ok(())
5065}
5066
5067fn default_app_root() -> Result<PathBuf> {
5068    if let Some(app_root) = std::env::var_os(APP_ROOT_ENV_VAR) {
5069        return Ok(PathBuf::from(app_root));
5070    }
5071
5072    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
5073
5074    #[cfg(windows)]
5075    {
5076        if has_current_package_identity() {
5077            return Ok(local_app_data
5078                .join("Packages")
5079                .join(PACKAGED_FAMILY_NAME)
5080                .join("LocalState"));
5081        }
5082        Ok(local_app_data.join("Devolutions").join("Pinget"))
5083    }
5084
5085    #[cfg(not(windows))]
5086    {
5087        Ok(local_app_data.join("pinget"))
5088    }
5089}
5090
5091#[cfg(windows)]
5092fn has_current_package_identity() -> bool {
5093    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
5094    // process has no AppX package identity.
5095    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
5096
5097    let mut length: u32 = 0;
5098    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
5099    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
5100    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
5101    rc != APPMODEL_ERROR_NO_PACKAGE
5102}
5103
5104fn store_path(app_root: &Path) -> PathBuf {
5105    app_root.join("sources.json")
5106}
5107
5108fn user_settings_path(app_root: &Path) -> PathBuf {
5109    if uses_packaged_layout(app_root) {
5110        app_root.join("settings.json")
5111    } else {
5112        app_root.join("user-settings.json")
5113    }
5114}
5115
5116fn admin_settings_path(app_root: &Path) -> PathBuf {
5117    app_root.join("admin-settings.json")
5118}
5119
5120#[cfg(test)]
5121fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
5122    source_state_dir_with_keying(app_root, source, false)
5123}
5124
5125fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5126    if uses_packaged_layout(app_root) {
5127        return app_root
5128            .join(packaged_source_type(source.kind))
5129            .join(sanitize_path_segment(&source.identifier));
5130    }
5131
5132    if identifier_keyed {
5133        return app_root.join("sources").join(sanitize_path_segment(&source.identifier));
5134    }
5135
5136    app_root.join("sources").join(sanitize_path_segment(&source.name))
5137}
5138
5139fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5140    if uses_packaged_layout(app_root) {
5141        return app_root
5142            .join(sanitize_path_segment(&source.identifier))
5143            .join("installed.db");
5144    }
5145
5146    source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db")
5147}
5148
5149#[cfg(test)]
5150fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
5151    source_state_dir(app_root, source).join("index.db")
5152}
5153
5154fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) {
5155    if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) {
5156        source.etag = Some(value.to_owned());
5157    }
5158
5159    if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) {
5160        source.last_modified = Some(value.to_owned());
5161    }
5162}
5163
5164/// Removes the `-wal` and `-shm` sidecars that SQLite/Turso leaves next to a
5165/// database file. Used after replacing `index.db` so a stale write-ahead log
5166/// from the previous database is not replayed against the new one.
5167fn remove_sqlite_sidecars(db_path: &Path) {
5168    for suffix in ["-wal", "-shm"] {
5169        let mut sidecar = db_path.as_os_str().to_owned();
5170        sidecar.push(suffix);
5171        let _ = fs::remove_file(PathBuf::from(sidecar));
5172    }
5173}
5174
5175#[cfg(windows)]
5176const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
5177
5178#[cfg(windows)]
5179fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
5180    let source_wide = source
5181        .as_os_str()
5182        .encode_wide()
5183        .chain(std::iter::once(0))
5184        .collect::<Vec<_>>();
5185    let target_wide = target
5186        .as_os_str()
5187        .encode_wide()
5188        .chain(std::iter::once(0))
5189        .collect::<Vec<_>>();
5190    let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
5191    // SAFETY: Both paths are converted to null-terminated UTF-16 buffers that
5192    // live for the duration of the call; MoveFileExW does not retain them.
5193    let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
5194    if ok == 0 {
5195        return Err(std::io::Error::last_os_error());
5196    }
5197    Ok(())
5198}
5199
5200#[cfg(windows)]
5201fn replace_file(source: &Path, target: &Path) -> Result<()> {
5202    let to_err = |error: std::io::Error| -> anyhow::Error {
5203        anyhow::Error::new(error).context(format!(
5204            "failed to replace {} with {}",
5205            target.display(),
5206            source.display()
5207        ))
5208    };
5209
5210    // Fast path: atomic replace. Succeeds whenever nothing holds the target open.
5211    let first = match move_replace_existing(source, target) {
5212        Ok(()) => return Ok(()),
5213        Err(error) => error,
5214    };
5215
5216    // A missing source or absent target is not a transient lock — fail fast
5217    // instead of spinning through the retry/stage-aside path.
5218    if !source.exists() || !target.exists() {
5219        return Err(to_err(first));
5220    }
5221
5222    // MOVEFILE_REPLACE_EXISTING is denied while another handle holds the target
5223    // open. In practice that handle is an on-access AV scan of the freshly
5224    // written file or a concurrent pinget process reading the index; both are
5225    // short-lived, so retry briefly with a small backoff before falling back.
5226    let mut last = first;
5227    for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
5228        std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
5229        match move_replace_existing(source, target) {
5230            Ok(()) => return Ok(()),
5231            Err(error) => last = error,
5232        }
5233    }
5234
5235    // Persistent holder: stage the existing index aside, then move the new file
5236    // into the freed name. Renaming the existing file succeeds even while a
5237    // reader keeps its handle, because SQLite/Turso opens with FILE_SHARE_DELETE.
5238    // The staged copy is only removed once the new file is in place, and restored
5239    // if the swap fails, so a working file is never lost.
5240    let staged = target.with_extension("old.tmp");
5241    if move_replace_existing(target, &staged).is_err() {
5242        return Err(to_err(last));
5243    }
5244    match move_replace_existing(source, target) {
5245        Ok(()) => {
5246            let _ = fs::remove_file(&staged);
5247            Ok(())
5248        }
5249        Err(error) => {
5250            let _ = move_replace_existing(&staged, target);
5251            Err(to_err(error))
5252        }
5253    }
5254}
5255
5256#[cfg(not(windows))]
5257fn replace_file(source: &Path, target: &Path) -> Result<()> {
5258    fs::rename(source, target)
5259        .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
5260}
5261
5262fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
5263    cache_root(app_root)
5264        .join(bucket)
5265        .join(sanitize_path_segment(identifier))
5266}
5267
5268fn load_store(app_root: &Path) -> Result<SourceStore> {
5269    if uses_packaged_layout(app_root)
5270        && let Some(store) = load_packaged_store(app_root)?
5271    {
5272        return Ok(store);
5273    }
5274
5275    let path = store_path(app_root);
5276    if !path.exists() {
5277        let store = SourceStore::default();
5278        save_store(app_root, &store)?;
5279        return Ok(store);
5280    }
5281
5282    let bytes = fs::read(path).context("failed to read source store")?;
5283    serde_json::from_slice(&bytes).context("failed to parse source store")
5284}
5285
5286fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> {
5287    if uses_system_winget_source_commands(app_root) {
5288        return Ok((
5289            EffectiveSourceMode::SystemWingetDirect,
5290            load_system_winget_source_store()?,
5291        ));
5292    }
5293
5294    match source_mode {
5295        SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5296        SourceMode::SystemWingetMirror => {
5297            let store = load_or_refresh_system_winget_mirror_store(app_root, false)?;
5298            Ok((EffectiveSourceMode::SystemWingetMirror, store))
5299        }
5300        SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) {
5301            Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)),
5302            Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5303        },
5304    }
5305}
5306
5307fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf {
5308    app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME)
5309}
5310
5311fn load_system_winget_mirror_store(app_root: &Path) -> Result<Option<SourceStore>> {
5312    let path = system_winget_mirror_store_path(app_root);
5313    if !path.exists() {
5314        return Ok(None);
5315    }
5316
5317    let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?;
5318    serde_json::from_slice(&bytes)
5319        .context("failed to parse system WinGet mirror source store")
5320        .map(Some)
5321}
5322
5323fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5324    write_json(system_winget_mirror_store_path(app_root), store)
5325}
5326
5327fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result<SourceStore> {
5328    if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? {
5329        return Ok(store);
5330    }
5331
5332    refresh_system_winget_mirror_store(app_root)
5333}
5334
5335fn refresh_system_winget_mirror_store(app_root: &Path) -> Result<SourceStore> {
5336    let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default();
5337    let mut exported = load_system_winget_source_store()?;
5338    merge_source_cache_metadata(&mut exported, &prior);
5339    save_system_winget_mirror_store(app_root, &exported)?;
5340    Ok(exported)
5341}
5342
5343fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) {
5344    let prior_by_identifier: HashMap<_, _> = prior
5345        .sources
5346        .iter()
5347        .map(|source| (source.identifier.to_ascii_lowercase(), source))
5348        .collect();
5349
5350    for source in &mut target.sources {
5351        let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else {
5352            continue;
5353        };
5354        source.last_update = previous.last_update;
5355        source.source_version = previous.source_version.clone();
5356        source.etag = previous.etag.clone();
5357        source.last_modified = previous.last_modified.clone();
5358    }
5359}
5360
5361fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5362    if uses_system_winget_source_commands(app_root) {
5363        bail!("System WinGet source settings must be modified through winget.");
5364    }
5365
5366    if uses_packaged_layout(app_root) {
5367        return save_packaged_store(app_root, store);
5368    }
5369
5370    write_json(store_path(app_root), store)
5371}
5372
5373fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
5374    let user_sources_path = packaged_user_sources_path(app_root)?;
5375    let metadata_path = app_root.join("sources_metadata");
5376    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
5377    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
5378    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
5379}
5380
5381fn load_packaged_store_from_streams(
5382    user_sources_yaml: Option<&str>,
5383    metadata_yaml: Option<&str>,
5384) -> Result<Option<SourceStore>> {
5385    if is_secure_settings_stub(user_sources_yaml) {
5386        return load_system_winget_source_store().map(Some);
5387    }
5388
5389    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
5390}
5391
5392fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5393    let user_sources_path = packaged_user_sources_path(app_root)?;
5394    if let Some(parent) = user_sources_path.parent() {
5395        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
5396    }
5397
5398    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
5399        .context("failed to write packaged user_sources stream")?;
5400    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
5401        .context("failed to write packaged source metadata")?;
5402    Ok(())
5403}
5404
5405fn uses_system_winget_source_commands(app_root: &Path) -> bool {
5406    if !uses_packaged_layout(app_root) {
5407        return false;
5408    }
5409
5410    #[cfg(windows)]
5411    {
5412        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
5413            return false;
5414        };
5415
5416        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
5417        is_secure_settings_stub(user_sources_yaml.as_deref())
5418    }
5419
5420    #[cfg(not(windows))]
5421    {
5422        false
5423    }
5424}
5425
5426fn is_secure_settings_stub(value: Option<&str>) -> bool {
5427    value
5428        .map(str::trim_start)
5429        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
5430}
5431
5432fn load_system_winget_source_store() -> Result<SourceStore> {
5433    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
5434    Ok(SourceStore {
5435        sources: parse_system_winget_source_export(&result.stdout)?,
5436    })
5437}
5438
5439fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
5440    run_checked_winget_source_command(
5441        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
5442        &format!("add WinGet source '{name}'"),
5443    )?;
5444    Ok(())
5445}
5446
5447fn system_winget_remove_source(name: &str) -> Result<()> {
5448    run_checked_winget_source_command(
5449        vec![
5450            "source".to_owned(),
5451            "remove".to_owned(),
5452            "--name".to_owned(),
5453            name.to_owned(),
5454            "--disable-interactivity".to_owned(),
5455        ],
5456        &format!("remove WinGet source '{name}'"),
5457    )?;
5458    Ok(())
5459}
5460
5461fn system_winget_reset_source(name: &str) -> Result<()> {
5462    run_checked_winget_source_command(
5463        vec![
5464            "source".to_owned(),
5465            "reset".to_owned(),
5466            "--name".to_owned(),
5467            name.to_owned(),
5468            "--force".to_owned(),
5469            "--disable-interactivity".to_owned(),
5470        ],
5471        &format!("reset WinGet source '{name}'"),
5472    )?;
5473    Ok(())
5474}
5475
5476fn system_winget_reset_sources() -> Result<()> {
5477    run_checked_winget_source_command(
5478        vec![
5479            "source".to_owned(),
5480            "reset".to_owned(),
5481            "--force".to_owned(),
5482            "--disable-interactivity".to_owned(),
5483        ],
5484        "reset WinGet sources",
5485    )?;
5486    Ok(())
5487}
5488
5489fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
5490    let mut args = vec!["source".to_owned(), "update".to_owned()];
5491    if let Some(name) = name {
5492        args.push("--name".to_owned());
5493        args.push(name.to_owned());
5494    }
5495
5496    args.push("--disable-interactivity".to_owned());
5497    let result = run_checked_winget_source_command(
5498        args,
5499        if name.is_some() {
5500            "update WinGet source"
5501        } else {
5502            "update WinGet sources"
5503        },
5504    )?;
5505    let detail = result.stdout.trim();
5506    Ok(if detail.is_empty() {
5507        "Done".to_owned()
5508    } else {
5509        detail.to_owned()
5510    })
5511}
5512
5513fn build_winget_source_export_arguments() -> Vec<String> {
5514    vec![
5515        "source".to_owned(),
5516        "export".to_owned(),
5517        "--disable-interactivity".to_owned(),
5518    ]
5519}
5520
5521fn build_winget_source_add_arguments(
5522    name: &str,
5523    arg: &str,
5524    kind: SourceKind,
5525    trust_level: &str,
5526    explicit: bool,
5527) -> Vec<String> {
5528    let mut args = vec![
5529        "source".to_owned(),
5530        "add".to_owned(),
5531        "--name".to_owned(),
5532        name.to_owned(),
5533        "--arg".to_owned(),
5534        arg.to_owned(),
5535        "--type".to_owned(),
5536        packaged_source_type(kind).to_owned(),
5537        "--disable-interactivity".to_owned(),
5538    ];
5539
5540    if !trust_level.trim().is_empty() {
5541        args.push("--trust-level".to_owned());
5542        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
5543            "trusted".to_owned()
5544        } else {
5545            "none".to_owned()
5546        });
5547    }
5548
5549    if explicit {
5550        args.push("--explicit".to_owned());
5551    }
5552
5553    args
5554}
5555
5556#[derive(Debug)]
5557struct WingetSourceCommandResult {
5558    exit_code: i32,
5559    stdout: String,
5560    stderr: String,
5561}
5562
5563type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
5564
5565static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
5566    RwLock::new(run_winget_source_command_process);
5567
5568fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
5569    let result = run_winget_source_command(&args)?;
5570    if result.exit_code == 0 {
5571        return Ok(result);
5572    }
5573
5574    let detail = [result.stderr.trim(), result.stdout.trim()]
5575        .into_iter()
5576        .filter(|value| !value.is_empty())
5577        .collect::<Vec<_>>()
5578        .join("\n");
5579    if detail.is_empty() {
5580        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
5581    }
5582
5583    bail!(
5584        "Failed to {action}; winget exited with code {}:\n{}",
5585        result.exit_code,
5586        detail
5587    );
5588}
5589
5590fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
5591    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
5592        .read()
5593        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
5594    runner(args)
5595}
5596
5597fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
5598    use std::process::Command;
5599
5600    let output = Command::new("winget")
5601        .args(args)
5602        .output()
5603        .context("failed to run winget source command")?;
5604
5605    Ok(WingetSourceCommandResult {
5606        exit_code: output.status.code().unwrap_or(-1),
5607        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5608        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5609    })
5610}
5611
5612fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
5613    let trimmed = output.trim();
5614    if trimmed.is_empty() {
5615        return Ok(Vec::new());
5616    }
5617
5618    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
5619        && let Some(sources) = parse_system_winget_source_export_value(&value)?
5620    {
5621        return Ok(sources);
5622    }
5623
5624    let mut sources = Vec::new();
5625    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
5626        if line.is_empty() {
5627            continue;
5628        }
5629
5630        let value: JsonValue = serde_json::from_str(line)
5631            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
5632        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
5633            bail!("WinGet source export returned a non-source line: {line}");
5634        };
5635        sources.append(&mut parsed);
5636    }
5637
5638    Ok(sources)
5639}
5640
5641fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
5642    match value {
5643        JsonValue::Array(items) => items
5644            .iter()
5645            .map(parse_system_winget_source_record)
5646            .collect::<Result<Vec<_>>>()
5647            .map(Some),
5648        JsonValue::Object(object) => {
5649            if let Some(JsonValue::Array(items)) = object.get("Sources") {
5650                items
5651                    .iter()
5652                    .map(parse_system_winget_source_record)
5653                    .collect::<Result<Vec<_>>>()
5654                    .map(Some)
5655            } else if object.contains_key("Name") {
5656                Ok(Some(vec![parse_system_winget_source_record(value)?]))
5657            } else {
5658                Ok(None)
5659            }
5660        }
5661        _ => Ok(None),
5662    }
5663}
5664
5665fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
5666    let object = value
5667        .as_object()
5668        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
5669    let name = required_json_string(object, "Name")?;
5670    let source_type = required_json_string(object, "Type")?;
5671    let arg = required_json_string(object, "Arg")?;
5672    let kind = parse_system_winget_source_kind(&source_type)?;
5673    let identifier = optional_json_string(object, "Identifier")
5674        .or_else(|| optional_json_string(object, "Data"))
5675        .unwrap_or_else(|| name.clone());
5676
5677    Ok(SourceRecord {
5678        name,
5679        kind,
5680        arg,
5681        identifier,
5682        trust_level: parse_system_winget_trust_level(object),
5683        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
5684        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
5685        last_update: None,
5686        source_version: None,
5687        etag: None,
5688        last_modified: None,
5689    })
5690}
5691
5692fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
5693    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
5694}
5695
5696fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
5697    match object.get(key)? {
5698        JsonValue::String(value) => Some(value.clone()),
5699        JsonValue::Number(value) => Some(value.to_string()),
5700        _ => None,
5701    }
5702}
5703
5704fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
5705    match object.get(key)? {
5706        JsonValue::Bool(value) => Some(*value),
5707        JsonValue::String(value) => value.parse().ok(),
5708        _ => None,
5709    }
5710}
5711
5712fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
5713    match object.get(key)? {
5714        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
5715        JsonValue::String(value) => value.parse().ok(),
5716        _ => None,
5717    }
5718}
5719
5720fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
5721    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5722        Ok(SourceKind::PreIndexed)
5723    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5724        Ok(SourceKind::Rest)
5725    } else {
5726        bail!("Unsupported WinGet source type '{value}'.")
5727    }
5728}
5729
5730fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
5731    let Some(value) = object.get("TrustLevel") else {
5732        return "None".to_owned();
5733    };
5734
5735    match value {
5736        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
5737        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
5738        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
5739        _ => "None".to_owned(),
5740    }
5741}
5742
5743fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
5744    value
5745        .as_str()
5746        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
5747}
5748
5749fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
5750    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
5751        return None;
5752    }
5753
5754    let mut sources = SourceStore::default().sources;
5755    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
5756        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5757            continue;
5758        };
5759
5760        if yaml_bool(entry.get("IsTombstone")) {
5761            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
5762            continue;
5763        }
5764
5765        let Some(mapped) = map_packaged_source_entry(&entry) else {
5766            continue;
5767        };
5768
5769        if let Some(existing) = sources
5770            .iter_mut()
5771            .find(|source| source.name.eq_ignore_ascii_case(&name))
5772        {
5773            *existing = mapped;
5774        } else {
5775            sources.push(mapped);
5776        }
5777    }
5778
5779    for entry in parse_packaged_yaml_entries(metadata_yaml) {
5780        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5781            continue;
5782        };
5783
5784        let Some(source) = sources
5785            .iter_mut()
5786            .find(|source| source.name.eq_ignore_ascii_case(&name))
5787        else {
5788            continue;
5789        };
5790
5791        if let Some(last_update) = entry
5792            .get("LastUpdate")
5793            .and_then(|value| value.as_deref())
5794            .and_then(parse_packaged_datetime)
5795        {
5796            source.last_update = Some(last_update);
5797        }
5798
5799        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
5800            && !source_version.is_empty()
5801        {
5802            source.source_version = Some(source_version);
5803        }
5804
5805        if let Some(etag) = entry.get("ETag").and_then(|value| value.clone())
5806            && !etag.is_empty()
5807        {
5808            source.etag = Some(etag);
5809        }
5810
5811        if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone())
5812            && !last_modified.is_empty()
5813        {
5814            source.last_modified = Some(last_modified);
5815        }
5816    }
5817
5818    Some(SourceStore { sources })
5819}
5820
5821fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
5822    let Some(yaml) = yaml else {
5823        return Vec::new();
5824    };
5825
5826    let mut entries = Vec::new();
5827    let mut current: Option<BTreeMap<String, Option<String>>> = None;
5828    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
5829        let line = raw_line.trim_end();
5830        let trimmed = line.trim();
5831        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
5832            continue;
5833        }
5834
5835        if let Some(remainder) = trimmed.strip_prefix("- ") {
5836            if let Some(entry) = current.take() {
5837                entries.push(entry);
5838            }
5839
5840            let mut entry = BTreeMap::new();
5841            add_packaged_yaml_key_value(&mut entry, remainder);
5842            current = Some(entry);
5843            continue;
5844        }
5845
5846        if let Some(entry) = current.as_mut() {
5847            add_packaged_yaml_key_value(entry, trimmed);
5848        }
5849    }
5850
5851    if let Some(entry) = current {
5852        entries.push(entry);
5853    }
5854
5855    entries
5856}
5857
5858fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
5859    let Some((key, value)) = line.split_once(':') else {
5860        return;
5861    };
5862
5863    entry.insert(
5864        key.trim().to_owned(),
5865        Some(unquote_yaml_scalar(value.trim()).to_owned()),
5866    );
5867}
5868
5869fn unquote_yaml_scalar(value: &str) -> &str {
5870    if value.len() >= 2 {
5871        let bytes = value.as_bytes();
5872        let first = bytes[0] as char;
5873        let last = bytes[value.len() - 1] as char;
5874        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
5875            return &value[1..value.len() - 1];
5876        }
5877    }
5878
5879    value
5880}
5881
5882fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
5883    let name = entry.get("Name").and_then(|value| value.clone())?;
5884    let source_type = entry.get("Type").and_then(|value| value.clone())?;
5885    let arg = entry.get("Arg").and_then(|value| value.clone())?;
5886    let kind = parse_packaged_source_kind(&source_type)?;
5887
5888    Some(SourceRecord {
5889        name: name.clone(),
5890        kind,
5891        arg,
5892        identifier: entry
5893            .get("Identifier")
5894            .and_then(|value| value.clone())
5895            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
5896            .unwrap_or(name),
5897        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
5898        explicit: yaml_bool(entry.get("Explicit")),
5899        priority: entry
5900            .get("Priority")
5901            .and_then(|value| value.as_deref())
5902            .and_then(|value| value.parse::<i32>().ok())
5903            .unwrap_or_default(),
5904        last_update: None,
5905        source_version: None,
5906        etag: None,
5907        last_modified: None,
5908    })
5909}
5910
5911fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
5912    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5913        Some(SourceKind::PreIndexed)
5914    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5915        Some(SourceKind::Rest)
5916    } else {
5917        None
5918    }
5919}
5920
5921fn parse_packaged_trust_level(value: Option<&str>) -> String {
5922    match value {
5923        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5924        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5925        _ => "None".to_owned(),
5926    }
5927}
5928
5929fn yaml_bool(value: Option<&Option<String>>) -> bool {
5930    value
5931        .and_then(|raw| raw.as_deref())
5932        .and_then(|raw| raw.parse::<bool>().ok())
5933        .unwrap_or(false)
5934}
5935
5936fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5937    if let Ok(unix_seconds) = value.parse::<i64>() {
5938        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5939    }
5940
5941    DateTime::parse_from_rfc3339(value)
5942        .ok()
5943        .map(|value| value.with_timezone(&Utc))
5944}
5945
5946fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5947    let mut lines = vec!["Sources:".to_owned()];
5948    for source in &store.sources {
5949        lines.push(format!("  - Name: {}", source.name));
5950        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
5951        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
5952        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
5953        lines.push(format!(
5954            "    TrustLevel: {}",
5955            if source.trust_level.eq_ignore_ascii_case("Trusted") {
5956                1
5957            } else {
5958                0
5959            }
5960        ));
5961        lines.push(format!("    Explicit: {}", source.explicit));
5962        lines.push(format!("    Priority: {}", source.priority));
5963        lines.push("    IsTombstone: false".to_owned());
5964    }
5965    lines.push(String::new());
5966    lines.join("\n")
5967}
5968
5969fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5970    let mut lines = vec!["Sources:".to_owned()];
5971    for source in &store.sources {
5972        if source.last_update.is_none()
5973            && source.source_version.is_none()
5974            && source.etag.is_none()
5975            && source.last_modified.is_none()
5976        {
5977            continue;
5978        }
5979
5980        lines.push(format!("  - Name: {}", source.name));
5981        if let Some(last_update) = source.last_update {
5982            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
5983        }
5984        if let Some(source_version) = &source.source_version {
5985            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
5986        }
5987        if let Some(etag) = &source.etag {
5988            lines.push(format!("    ETag: {}", yaml_scalar(etag)));
5989        }
5990        if let Some(last_modified) = &source.last_modified {
5991            lines.push(format!("    LastModified: {}", yaml_scalar(last_modified)));
5992        }
5993    }
5994    lines.push(String::new());
5995    lines.join("\n")
5996}
5997
5998fn yaml_scalar(value: &str) -> String {
5999    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
6000        format!("\"{}\"", value.replace('"', "\\\""))
6001    } else {
6002        value.to_owned()
6003    }
6004}
6005
6006fn uses_packaged_layout(app_root: &Path) -> bool {
6007    #[cfg(windows)]
6008    {
6009        try_get_packaged_local_state_root(app_root).is_some()
6010    }
6011
6012    #[cfg(not(windows))]
6013    {
6014        let _ = app_root;
6015        false
6016    }
6017}
6018
6019#[cfg(windows)]
6020fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
6021    let local_state = app_root.file_name()?.to_string_lossy();
6022    if !local_state.eq_ignore_ascii_case("LocalState") {
6023        return None;
6024    }
6025
6026    let family = app_root.parent()?.file_name()?.to_string_lossy();
6027    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
6028        return None;
6029    }
6030
6031    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
6032    if !packages.eq_ignore_ascii_case("Packages") {
6033        return None;
6034    }
6035
6036    Some(app_root.to_path_buf())
6037}
6038
6039#[cfg(windows)]
6040fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
6041    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
6042    Ok(packaged_secure_settings_root()?.join("user_sources"))
6043}
6044
6045#[cfg(not(windows))]
6046fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
6047    bail!("packaged WinGet source streams are only available on Windows")
6048}
6049
6050#[cfg(windows)]
6051fn packaged_secure_settings_root() -> Result<PathBuf> {
6052    let program_data = std::env::var_os("PROGRAMDATA")
6053        .map(PathBuf::from)
6054        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
6055    Ok(program_data
6056        .join("Microsoft")
6057        .join("WinGet")
6058        .join(current_user_sid()?)
6059        .join("settings")
6060        .join("pkg")
6061        .join(PACKAGED_NAME))
6062}
6063
6064#[cfg(windows)]
6065#[allow(clippy::multiple_unsafe_ops_per_block)]
6066fn current_user_sid() -> Result<String> {
6067    fn close_token(token: HANDLE) {
6068        if !token.is_null() {
6069            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
6070            unsafe {
6071                CloseHandle(token);
6072            }
6073        }
6074    }
6075
6076    let mut token: HANDLE = std::ptr::null_mut();
6077    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
6078    let current_process = unsafe { GetCurrentProcess() };
6079    // SAFETY: token points to valid storage for the process token handle on success.
6080    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
6081        bail!("failed to open current process token")
6082    }
6083
6084    let mut length = 0;
6085    // SAFETY: The null buffer call asks Windows for the required buffer length.
6086    unsafe {
6087        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
6088    }
6089
6090    let element_size = size_of::<TOKEN_USER>();
6091    let element_count = (length as usize).div_ceil(element_size);
6092    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
6093    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
6094    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
6095        close_token(token);
6096        bail!("failed to get current user token information")
6097    }
6098
6099    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
6100    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
6101    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
6102    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
6103    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
6104        close_token(token);
6105        bail!("failed to convert current user SID")
6106    }
6107
6108    let mut len = 0usize;
6109    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
6110    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
6111        len += 1;
6112    }
6113
6114    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
6115    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
6116    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
6117    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
6118    unsafe {
6119        LocalFree(sid_ptr.cast());
6120    }
6121    close_token(token);
6122    Ok(sid)
6123}
6124
6125fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
6126    app_root.join("Microsoft").join("Windows Package Manager")
6127}
6128
6129fn cache_root(app_root: &Path) -> PathBuf {
6130    if uses_packaged_layout(app_root) {
6131        packaged_file_cache_root(app_root)
6132    } else {
6133        default_cache_root_fallback()
6134    }
6135}
6136
6137fn default_cache_root_fallback() -> PathBuf {
6138    std::env::temp_dir().join("cache")
6139}
6140
6141fn packaged_source_type(kind: SourceKind) -> &'static str {
6142    match kind {
6143        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
6144        SourceKind::Rest => "Microsoft.Rest",
6145    }
6146}
6147
6148fn sanitize_path_segment(value: &str) -> String {
6149    value
6150        .chars()
6151        .map(|ch| match ch {
6152            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
6153            _ => ch,
6154        })
6155        .collect()
6156}
6157
6158fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
6159    if !path.exists() {
6160        return Ok(JsonMap::new());
6161    }
6162
6163    let bytes = fs::read(path).context("failed to read JSON file")?;
6164    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
6165    Ok(match value {
6166        JsonValue::Object(object) => object,
6167        _ => JsonMap::new(),
6168    })
6169}
6170
6171fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
6172    write_json(path, value)
6173}
6174
6175fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
6176    if let Some(parent) = path.parent() {
6177        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
6178    }
6179
6180    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
6181    fs::write(path, bytes).context("failed to write JSON file")
6182}
6183
6184fn merge_json_objects(
6185    current: &JsonMap<String, JsonValue>,
6186    update: &JsonMap<String, JsonValue>,
6187) -> JsonMap<String, JsonValue> {
6188    let mut merged = current.clone();
6189    for (key, value) in update {
6190        match (merged.get(key), value) {
6191            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
6192                merged.insert(
6193                    key.clone(),
6194                    JsonValue::Object(merge_json_objects(current_object, update_object)),
6195                );
6196            }
6197            _ => {
6198                merged.insert(key.clone(), value.clone());
6199            }
6200        }
6201    }
6202
6203    merged
6204}
6205
6206fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
6207    match expected {
6208        JsonValue::Object(expected_object) => match current {
6209            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
6210                current_object
6211                    .get(key)
6212                    .is_some_and(|current_child| json_contains(current_child, expected_child))
6213            }),
6214            _ => false,
6215        },
6216        JsonValue::Array(expected_array) => match current {
6217            JsonValue::Array(current_array) => {
6218                current_array.len() == expected_array.len()
6219                    && current_array
6220                        .iter()
6221                        .zip(expected_array.iter())
6222                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
6223            }
6224            _ => false,
6225        },
6226        _ => current == expected,
6227    }
6228}
6229
6230fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
6231    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
6232        "none" | "default" => Ok("None".to_owned()),
6233        "trusted" => Ok("Trusted".to_owned()),
6234        other => bail!("unsupported source trust level: {other}"),
6235    }
6236}
6237
6238fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
6239    SUPPORTED_ADMIN_SETTINGS
6240        .iter()
6241        .copied()
6242        .find(|setting| setting.eq_ignore_ascii_case(name))
6243        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
6244}
6245
6246fn default_source_trust_level() -> String {
6247    "None".to_owned()
6248}
6249
6250fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
6251where
6252    F: FnMut(&SqlRow) -> Result<T>,
6253{
6254    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6255        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6256    let mut result = Vec::new();
6257
6258    while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
6259        result.push(map(&row)?);
6260    }
6261
6262    Ok(result)
6263}
6264
6265fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
6266where
6267    F: FnOnce(&SqlRow) -> Result<T>,
6268{
6269    let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6270        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6271
6272    match pollster::block_on(rows.next()).context("failed to read SQL row")? {
6273        Some(row) => Ok(Some(map(&row)?)),
6274        None => Ok(None),
6275    }
6276}
6277
6278fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
6279    pollster::block_on(connection.execute(sql, params_from_iter(params)))
6280        .with_context(|| format!("failed to execute SQL statement: {sql}"))
6281}
6282
6283fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
6284    pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
6285}
6286
6287fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
6288    row.get_value(index)
6289        .with_context(|| format!("failed to read SQL column {index}"))
6290}
6291
6292fn row_string(row: &SqlRow, index: usize) -> Result<String> {
6293    match row_value(row, index)? {
6294        SqlValue::Text(value) => Ok(value),
6295        SqlValue::Integer(value) => Ok(value.to_string()),
6296        SqlValue::Real(value) => Ok(value.to_string()),
6297        SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
6298        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6299    }
6300}
6301
6302fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6303    match row_value(row, index)? {
6304        SqlValue::Null => Ok(None),
6305        SqlValue::Text(value) => Ok(Some(value)),
6306        SqlValue::Integer(value) => Ok(Some(value.to_string())),
6307        SqlValue::Real(value) => Ok(Some(value.to_string())),
6308        SqlValue::Blob(value) => Ok(Some(
6309            String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
6310        )),
6311    }
6312}
6313
6314fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
6315    match row_value(row, index)? {
6316        SqlValue::Integer(value) => Ok(value),
6317        SqlValue::Text(value) => value
6318            .parse::<i64>()
6319            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
6320        SqlValue::Real(value) => f64_to_i64_checked(value, index),
6321        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6322        SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
6323    }
6324}
6325
6326fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
6327    match row_value(row, index)? {
6328        SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
6329        SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
6330        SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6331        SqlValue::Integer(value) => Ok(format!("{value:x}")),
6332        SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
6333    }
6334}
6335
6336fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6337    match row_value(row, index)? {
6338        SqlValue::Null => Ok(None),
6339        SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
6340        SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
6341        SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
6342        SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
6343    }
6344}
6345
6346fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
6347    if !value.is_finite() {
6348        bail!("SQL column {index} contained a non-finite floating-point value");
6349    }
6350
6351    if value.fract() != 0.0 {
6352        bail!("SQL column {index} contained a non-integer floating-point value");
6353    }
6354
6355    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
6356        bail!("SQL column {index} contained a floating-point value outside the i64 range");
6357    }
6358
6359    format!("{value:.0}")
6360        .parse::<i64>()
6361        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
6362}
6363
6364fn bytes_to_hex(bytes: &[u8]) -> String {
6365    let mut result = String::with_capacity(bytes.len() * 2);
6366    for byte in bytes {
6367        result.push_str(&format!("{byte:02x}"));
6368    }
6369    result
6370}
6371
6372fn query_v2_matches(
6373    connection: &Connection,
6374    query: &PackageQuery,
6375    semantics: SearchSemantics,
6376) -> Result<(Vec<V2SearchRow>, bool)> {
6377    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
6378    let limit = source_fetch_results(query, semantics);
6379    let sql = format!(
6380        "SELECT rowid, id, name, moniker, latest_version, hash \
6381         FROM packages WHERE {where_clause} LIMIT {}",
6382        limit + 1
6383    );
6384    let mut rows = query_rows(
6385        connection,
6386        &sql,
6387        params.into_iter().map(SqlValue::Text).collect(),
6388        |row| {
6389            Ok(V2SearchRow {
6390                package_rowid: row_i64(row, 0)?,
6391                id: row_string(row, 1)?,
6392                name: row_string(row, 2)?,
6393                moniker: row_opt_string(row, 3)?,
6394                version: row_string(row, 4)?,
6395                package_hash: row_hex_string(row, 5)?,
6396                match_criteria: None,
6397            })
6398        },
6399    )?;
6400    for row in &mut rows {
6401        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
6402    }
6403    let truncated = rows.len() > limit;
6404    rows.truncate(limit);
6405    Ok((rows, truncated))
6406}
6407
6408fn query_v1_matches(
6409    connection: &Connection,
6410    query: &PackageQuery,
6411    semantics: SearchSemantics,
6412) -> Result<(Vec<V1SearchRow>, bool)> {
6413    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
6414    let limit = source_fetch_results(query, semantics);
6415    let sql = format!(
6416        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
6417         FROM manifest \
6418         JOIN ids ON manifest.id = ids.rowid \
6419         JOIN names ON manifest.name = names.rowid \
6420         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
6421         JOIN versions ON manifest.version = versions.rowid \
6422         JOIN channels ON manifest.channel = channels.rowid \
6423         WHERE {where_clause} LIMIT {}",
6424        limit + 1
6425    );
6426    let mut rows = query_rows(
6427        connection,
6428        &sql,
6429        params.into_iter().map(SqlValue::Text).collect(),
6430        |row| {
6431            Ok(V1SearchRow {
6432                manifest_rowid: row_i64(row, 0)?,
6433                package_rowid: row_i64(row, 1)?,
6434                version: row_string(row, 2)?,
6435                channel: row_string(row, 3)?,
6436                id: row_string(row, 4)?,
6437                name: row_string(row, 5)?,
6438                moniker: row_opt_string(row, 6)?,
6439                match_criteria: None,
6440            })
6441        },
6442    )?;
6443    for row in &mut rows {
6444        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
6445    }
6446    let truncated = rows.len() > limit;
6447    rows.truncate(limit);
6448    Ok((rows, truncated))
6449}
6450
6451fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
6452    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
6453
6454    for row in rows {
6455        match grouped.get(&row.package_rowid) {
6456            Some(existing)
6457                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
6458                    != Ordering::Less => {}
6459            _ => {
6460                grouped.insert(row.package_rowid, row);
6461            }
6462        }
6463    }
6464
6465    grouped.into_values().collect()
6466}
6467
6468fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
6469    let has_hash = table_has_column(connection, "manifest", "hash")?;
6470    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
6471    let sql = format!(
6472        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
6473         FROM manifest \
6474         JOIN versions ON manifest.version = versions.rowid \
6475         JOIN channels ON manifest.channel = channels.rowid \
6476         WHERE manifest.id = ?1"
6477    );
6478    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
6479        Ok(V1VersionRow {
6480            version: row_string(row, 0)?,
6481            channel: row_string(row, 1)?,
6482            pathpart_id: row_i64(row, 2)?,
6483            manifest_hash: row_opt_hex_string(row, 3)?,
6484        })
6485    })
6486}
6487
6488fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
6489    let mut parts = Vec::new();
6490    let mut current = Some(pathpart_id);
6491
6492    while let Some(id) = current {
6493        let (parent, pathpart) = query_optional_value(
6494            connection,
6495            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
6496            vec![SqlValue::Integer(id)],
6497            |row| {
6498                Ok((
6499                    row_opt_string(row, 0)?
6500                        .and_then(|value| value.parse::<i64>().ok())
6501                        .filter(|value| *value != 0),
6502                    row_string(row, 1)?,
6503                ))
6504            },
6505        )?
6506        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
6507        parts.push(pathpart);
6508        current = parent;
6509    }
6510
6511    parts.reverse();
6512    Ok(parts.join("/"))
6513}
6514
6515fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
6516    format!(
6517        "packages/{}/{}/versionData.mszyml",
6518        package_id,
6519        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
6520    )
6521}
6522
6523fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
6524    let message = format!("{error:#}").to_ascii_lowercase();
6525    message.contains("no such table") && message.contains("packages")
6526}
6527
6528fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
6529    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
6530        row_string(row, 1)
6531    })? {
6532        if value.eq_ignore_ascii_case(column) {
6533            return Ok(true);
6534        }
6535    }
6536
6537    Ok(false)
6538}
6539
6540fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
6541    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
6542    let id_column = if v2 { "id" } else { "ids.id" };
6543    let name_column = if v2 { "name" } else { "names.name" };
6544    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
6545    let exact_match = query.exact || semantics == SearchSemantics::Single;
6546    let mut params = Vec::new();
6547
6548    if let Some(value) = &query.id {
6549        return (
6550            single_field_condition(id_column, value, exact_match, &mut params),
6551            params,
6552        );
6553    }
6554    if let Some(value) = &query.name {
6555        return (
6556            single_field_condition(name_column, value, exact_match, &mut params),
6557            params,
6558        );
6559    }
6560    if let Some(value) = &query.moniker {
6561        return (
6562            single_field_condition(moniker_column, value, exact_match, &mut params),
6563            params,
6564        );
6565    }
6566    if let Some(value) = &query.tag {
6567        return (
6568            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
6569            params,
6570        );
6571    }
6572    if let Some(value) = &query.command {
6573        return (
6574            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
6575            params,
6576        );
6577    }
6578    if let Some(value) = &query.query {
6579        if exact_match {
6580            let conditions = [
6581                single_field_condition(id_column, value, true, &mut params),
6582                single_field_condition(name_column, value, true, &mut params),
6583                single_field_condition(moniker_column, value, true, &mut params),
6584            ];
6585            return (format!("({})", conditions.join(" OR ")), params);
6586        }
6587
6588        let mut conditions = vec![
6589            single_field_condition(id_column, value, false, &mut params),
6590            single_field_condition(name_column, value, false, &mut params),
6591            single_field_condition(moniker_column, value, false, &mut params),
6592        ];
6593        if semantics == SearchSemantics::Many {
6594            conditions.push(mapped_field_condition(
6595                v2,
6596                "tag",
6597                value,
6598                rowid_column,
6599                false,
6600                &mut params,
6601            ));
6602            conditions.push(mapped_field_condition(
6603                v2,
6604                "command",
6605                value,
6606                rowid_column,
6607                false,
6608                &mut params,
6609            ));
6610        }
6611        return (format!("({})", conditions.join(" OR ")), params);
6612    }
6613
6614    ("1 = 1".to_owned(), Vec::new())
6615}
6616
6617fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
6618    params.push(match_parameter(value, exact));
6619    format!("{column} LIKE ?{}", params.len())
6620}
6621
6622fn mapped_field_condition(
6623    v2: bool,
6624    value_name: &str,
6625    value: &str,
6626    rowid_column: &str,
6627    exact: bool,
6628    params: &mut Vec<String>,
6629) -> String {
6630    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
6631        (
6632            format!("{value_name}s2"),
6633            format!("{value_name}s2_map"),
6634            value_name.to_owned(),
6635            "package".to_owned(),
6636        )
6637    } else {
6638        (
6639            format!("{value_name}s"),
6640            format!("{value_name}s_map"),
6641            value_name.to_owned(),
6642            "manifest".to_owned(),
6643        )
6644    };
6645    params.push(match_parameter(value, exact));
6646    let parameter = params.len();
6647    // Use a non-correlated `rowid IN (...)` subquery rather than a correlated
6648    // `EXISTS (... WHERE map.owner = packages.rowid ...)`. The correlated form
6649    // re-scans the (un-indexed) map table once per candidate package row, which
6650    // is quadratic: on the real winget index it takes ~19s under SQLite and
6651    // effectively hangs under the Turso engine. The `IN` form evaluates the
6652    // owner-id set a single time, collapsing the search back to milliseconds.
6653    format!(
6654        "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
6655         FROM {map_table_name} JOIN {table_name} ON \
6656         {map_table_name}.{value_name} = {table_name}.rowid \
6657         WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
6658    )
6659}
6660
6661fn match_parameter(value: &str, exact: bool) -> String {
6662    if exact { value.to_owned() } else { format!("%{value}%") }
6663}
6664
6665fn build_rest_search_body(
6666    query: &PackageQuery,
6667    info: &RestInformation,
6668    semantics: SearchSemantics,
6669) -> Result<JsonValue> {
6670    let mut root = serde_json::Map::new();
6671    root.insert(
6672        "MaximumResults".to_owned(),
6673        JsonValue::from(source_fetch_results(query, semantics) as u64),
6674    );
6675    let exact_match = query.exact || semantics == SearchSemantics::Single;
6676
6677    if let Some(value) = &query.query {
6678        if semantics == SearchSemantics::Single {
6679            let mut filters = vec![
6680                rest_filter("PackageIdentifier", value, true),
6681                rest_filter("PackageName", value, true),
6682                rest_filter("Moniker", value, true),
6683            ];
6684            append_required_rest_filters(&mut filters, info);
6685            root.insert("Filters".to_owned(), JsonValue::Array(filters));
6686            return Ok(JsonValue::Object(root));
6687        }
6688
6689        root.insert(
6690            "Query".to_owned(),
6691            serde_json::json!({
6692                "KeyWord": value,
6693                "MatchType": if exact_match { "Exact" } else { "Substring" },
6694            }),
6695        );
6696    }
6697
6698    let mut filters = Vec::new();
6699    if let Some(value) = &query.id {
6700        filters.push(rest_filter("PackageIdentifier", value, exact_match));
6701    }
6702    if let Some(value) = &query.name {
6703        filters.push(rest_filter("PackageName", value, exact_match));
6704    }
6705    if let Some(value) = &query.moniker {
6706        filters.push(rest_filter("Moniker", value, exact_match));
6707    }
6708    if let Some(value) = &query.tag {
6709        filters.push(rest_filter("Tag", value, true));
6710    }
6711    if let Some(value) = &query.command {
6712        filters.push(rest_filter("Command", value, true));
6713    }
6714    append_required_rest_filters(&mut filters, info);
6715
6716    if !filters.is_empty() {
6717        root.insert("Filters".to_owned(), JsonValue::Array(filters));
6718    }
6719
6720    Ok(JsonValue::Object(root))
6721}
6722
6723fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
6724    if info
6725        .required_package_match_fields
6726        .iter()
6727        .any(|field| field.eq_ignore_ascii_case("Market"))
6728    {
6729        filters.push(rest_filter("Market", &default_market(), true));
6730    }
6731}
6732
6733fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
6734    serde_json::json!({
6735        "PackageMatchField": field,
6736        "RequestMatch": {
6737            "KeyWord": value,
6738            "MatchType": if exact { "Exact" } else { "Substring" },
6739        }
6740    })
6741}
6742
6743fn max_results(query: &PackageQuery) -> usize {
6744    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
6745}
6746
6747fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
6748    match semantics {
6749        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
6750        SearchSemantics::Single => max_results(query),
6751    }
6752}
6753
6754fn infer_preindexed_match_criteria_v2(
6755    connection: &Connection,
6756    row: &V2SearchRow,
6757    query: &PackageQuery,
6758    semantics: SearchSemantics,
6759) -> Result<Option<String>> {
6760    infer_match_criteria(
6761        &row.id,
6762        &row.name,
6763        row.moniker.as_deref(),
6764        query,
6765        semantics,
6766        |value| {
6767            find_mapped_value_v2(
6768                connection,
6769                "tags2",
6770                "tags2_map",
6771                "tag",
6772                row.package_rowid,
6773                value,
6774                query.exact,
6775            )
6776        },
6777        |value| {
6778            find_mapped_value_v2(
6779                connection,
6780                "commands2",
6781                "commands2_map",
6782                "command",
6783                row.package_rowid,
6784                value,
6785                query.exact,
6786            )
6787        },
6788    )
6789}
6790
6791fn infer_preindexed_match_criteria_v1(
6792    connection: &Connection,
6793    row: &V1SearchRow,
6794    query: &PackageQuery,
6795    semantics: SearchSemantics,
6796) -> Result<Option<String>> {
6797    infer_match_criteria(
6798        &row.id,
6799        &row.name,
6800        row.moniker.as_deref(),
6801        query,
6802        semantics,
6803        |value| {
6804            find_mapped_value_v1(
6805                connection,
6806                "tags",
6807                "tags_map",
6808                "tag",
6809                row.manifest_rowid,
6810                value,
6811                query.exact,
6812            )
6813        },
6814        |value| {
6815            find_mapped_value_v1(
6816                connection,
6817                "commands",
6818                "commands_map",
6819                "command",
6820                row.manifest_rowid,
6821                value,
6822                query.exact,
6823            )
6824        },
6825    )
6826}
6827
6828fn infer_match_criteria<FTag, FCommand>(
6829    id: &str,
6830    name: &str,
6831    moniker: Option<&str>,
6832    query: &PackageQuery,
6833    semantics: SearchSemantics,
6834    find_tag: FTag,
6835    find_command: FCommand,
6836) -> Result<Option<String>>
6837where
6838    FTag: FnOnce(&str) -> Result<Option<String>>,
6839    FCommand: FnOnce(&str) -> Result<Option<String>>,
6840{
6841    if let Some(value) = &query.tag {
6842        return Ok(Some(format_match_criteria("Tag", value)));
6843    }
6844    if let Some(value) = &query.command {
6845        return Ok(Some(format_match_criteria("Command", value)));
6846    }
6847    if let Some(value) = &query.moniker {
6848        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
6849    }
6850    if semantics == SearchSemantics::Single {
6851        return Ok(None);
6852    }
6853    if let Some(value) = &query.query {
6854        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
6855            return Ok(None);
6856        }
6857        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
6858            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
6859        }
6860        if let Some(tag) = find_tag(value)? {
6861            return Ok(Some(format_match_criteria("Tag", &tag)));
6862        }
6863        if let Some(command) = find_command(value)? {
6864            return Ok(Some(format_match_criteria("Command", &command)));
6865        }
6866    }
6867    Ok(None)
6868}
6869
6870fn find_mapped_value_v2(
6871    connection: &Connection,
6872    table_name: &str,
6873    map_table_name: &str,
6874    value_name: &str,
6875    package_rowid: i64,
6876    query: &str,
6877    exact: bool,
6878) -> Result<Option<String>> {
6879    let sql = format!(
6880        "SELECT {table_name}.{value_name} FROM {map_table_name} \
6881         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6882         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
6883    );
6884    let values = query_rows(
6885        connection,
6886        &sql,
6887        vec![
6888            SqlValue::Integer(package_rowid),
6889            SqlValue::Text(match_parameter(query, exact)),
6890        ],
6891        |row| row_string(row, 0),
6892    )?;
6893    Ok(select_best_text_match(values, query, exact))
6894}
6895
6896fn find_mapped_value_v1(
6897    connection: &Connection,
6898    table_name: &str,
6899    map_table_name: &str,
6900    value_name: &str,
6901    manifest_rowid: i64,
6902    query: &str,
6903    exact: bool,
6904) -> Result<Option<String>> {
6905    let sql = format!(
6906        "SELECT {table_name}.{value_name} FROM {map_table_name} \
6907         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6908         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
6909    );
6910    let values = query_rows(
6911        connection,
6912        &sql,
6913        vec![
6914            SqlValue::Integer(manifest_rowid),
6915            SqlValue::Text(match_parameter(query, exact)),
6916        ],
6917        |row| row_string(row, 0),
6918    )?;
6919    Ok(select_best_text_match(values, query, exact))
6920}
6921
6922fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
6923    if let Some(value) = &query.tag {
6924        return Some(format_match_criteria("Tag", value));
6925    }
6926    if let Some(value) = &query.command {
6927        return Some(format_match_criteria("Command", value));
6928    }
6929    if let Some(value) = &query.moniker {
6930        return Some(format_match_criteria("Moniker", value));
6931    }
6932    if semantics == SearchSemantics::Single {
6933        return None;
6934    }
6935    let value = query.query.as_deref()?;
6936    if json_string(item, "PackageIdentifier")
6937        .as_deref()
6938        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6939        || json_string(item, "PackageName")
6940            .as_deref()
6941            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6942    {
6943        return None;
6944    }
6945    json_string(item, "Moniker")
6946        .filter(|candidate| matches_text(candidate, value, query.exact))
6947        .map(|candidate| format_match_criteria("Moniker", &candidate))
6948}
6949
6950fn format_match_criteria(field: &str, value: &str) -> String {
6951    format!("{field}: {value}")
6952}
6953
6954fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6955    if exact {
6956        candidate.eq_ignore_ascii_case(query)
6957    } else {
6958        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6959    }
6960}
6961
6962fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6963    let mut score = 0;
6964
6965    if let Some(value) = &query.query {
6966        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6967        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6968        if let Some(moniker) = candidate.moniker.as_deref() {
6969            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6970        }
6971    }
6972
6973    if let Some(value) = &query.id {
6974        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6975    }
6976    if let Some(value) = &query.name {
6977        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6978    }
6979    if let Some(value) = &query.moniker
6980        && let Some(moniker) = candidate.moniker.as_deref()
6981    {
6982        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6983    }
6984    if let Some(value) = &query.tag
6985        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6986    {
6987        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6988    }
6989    if let Some(value) = &query.command
6990        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6991    {
6992        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6993    }
6994
6995    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6996        score += 5;
6997    }
6998    if search_match_has_unknown_version(candidate) {
6999        score = score.saturating_sub(10);
7000    }
7001
7002    score
7003}
7004
7005fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
7006    candidate
7007        .version
7008        .as_deref()
7009        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
7010}
7011
7012/// Builds the YAML manifest emitted next to the installer by `winget download`.
7013///
7014/// `documents` is the merged singleton JSON the source provided; we narrow the
7015/// `Installers` array down to just the resolved installer so the file describes
7016/// exactly the binary that was written, mirroring native winget behavior. The
7017/// typed `manifest` overrides identity fields (id, version, name, channel) on
7018/// the JSON because callers like `manifest_for_match` correct those after the
7019/// initial YAML parse.
7020fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
7021    let mut document = match documents {
7022        JsonValue::Object(map) => map.clone(),
7023        _ => serde_json::Map::new(),
7024    };
7025
7026    document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
7027    document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
7028    document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
7029    if !manifest.channel.is_empty() {
7030        document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
7031    } else {
7032        document.remove("Channel");
7033    }
7034
7035    // Replace the multi-installer array with the single resolved installer so
7036    // the emitted manifest describes the file the user actually downloaded.
7037    let mut single_installer = serde_json::Map::new();
7038    if let Some(arch) = installer.architecture.as_ref() {
7039        single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
7040    }
7041    if let Some(installer_type) = installer.installer_type.as_ref() {
7042        single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
7043    }
7044    if let Some(url) = installer.url.as_ref() {
7045        single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
7046    }
7047    if let Some(sha) = installer.sha256.as_ref() {
7048        single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
7049    }
7050    if let Some(scope) = installer.scope.as_ref() {
7051        single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
7052    }
7053    if let Some(locale) = installer.locale.as_ref() {
7054        single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
7055    }
7056    if let Some(product_code) = installer.product_code.as_ref() {
7057        single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
7058    }
7059    if let Some(release_date) = installer.release_date.as_ref() {
7060        single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
7061    }
7062
7063    document.insert(
7064        "Installers".to_owned(),
7065        JsonValue::Array(vec![JsonValue::Object(single_installer)]),
7066    );
7067    document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
7068
7069    let value = JsonValue::Object(document);
7070    serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
7071}
7072
7073/// Computes the file name `winget download` uses for the YAML manifest
7074/// emitted next to the installer:
7075/// `{PackageName}_{Version?}_{Scope?}_{Architecture}_{InstallerType}_{Locale?}.yaml`.
7076/// Mirrors `GetInstallerDownloadOnlyFileName` in winget-cli's DownloadFlow.
7077fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
7078    let mut filename = derive_download_filename_stem(manifest, installer);
7079    filename.push_str(".yaml");
7080    filename
7081}
7082
7083fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
7084    let mut stem = sanitize_filename_component(&manifest.name);
7085    if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
7086        stem.push('_');
7087        stem.push_str(&sanitize_filename_component(&manifest.version));
7088    }
7089    if let Some(scope) = installer.scope.as_deref() {
7090        stem.push('_');
7091        stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
7092    }
7093    let architecture = installer.architecture.as_deref().unwrap_or("neutral");
7094    stem.push('_');
7095    stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
7096
7097    let installer_type = installer.installer_type.as_deref().unwrap_or("");
7098    if !installer_type.is_empty() {
7099        stem.push('_');
7100        stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
7101    }
7102    if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
7103        stem.push('_');
7104        stem.push_str(&sanitize_filename_component(locale));
7105    }
7106
7107    stem
7108}
7109
7110fn capitalize_first(value: &str) -> String {
7111    let mut chars = value.chars();
7112    match chars.next() {
7113        Some(first) => {
7114            let mut out = first.to_ascii_uppercase().to_string();
7115            out.push_str(&chars.as_str().to_ascii_lowercase());
7116            out
7117        }
7118        None => String::new(),
7119    }
7120}
7121
7122fn format_architecture(value: &str) -> String {
7123    match value.to_ascii_lowercase().as_str() {
7124        "x64" => "X64".to_owned(),
7125        "x86" => "X86".to_owned(),
7126        "arm" => "Arm".to_owned(),
7127        "arm64" => "Arm64".to_owned(),
7128        "neutral" => "Neutral".to_owned(),
7129        other => capitalize_first(other),
7130    }
7131}
7132
7133fn sanitize_filename_component(value: &str) -> String {
7134    value
7135        .chars()
7136        .map(|ch| match ch {
7137            '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
7138            ch if ch.is_control() => '_',
7139            ch => ch,
7140        })
7141        .collect()
7142}
7143
7144fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
7145    if manifest_path.is_file() {
7146        return Ok(manifest_path.to_path_buf());
7147    }
7148
7149    if !manifest_path.is_dir() {
7150        bail!("Manifest path not found: {}", manifest_path.display());
7151    }
7152
7153    let candidate = fs::read_dir(manifest_path)?
7154        .filter_map(|entry| entry.ok())
7155        .map(|entry| entry.path())
7156        .filter(|path| {
7157            path.extension()
7158                .and_then(|value| value.to_str())
7159                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
7160        })
7161        .min();
7162
7163    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
7164}
7165
7166fn score_text_match(
7167    candidate: &str,
7168    query: &str,
7169    exact: bool,
7170    exact_score: usize,
7171    prefix_score: usize,
7172    substring_score: usize,
7173) -> usize {
7174    if candidate.eq_ignore_ascii_case(query) {
7175        return exact_score;
7176    }
7177    if exact {
7178        return 0;
7179    }
7180
7181    let candidate_lower = candidate.to_ascii_lowercase();
7182    let query_lower = query.to_ascii_lowercase();
7183    if candidate_lower.starts_with(&query_lower) {
7184        prefix_score
7185    } else if candidate_lower.contains(&query_lower) {
7186        substring_score
7187    } else {
7188        0
7189    }
7190}
7191
7192fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
7193where
7194    I: IntoIterator<Item = String>,
7195{
7196    values
7197        .into_iter()
7198        .filter_map(|candidate| {
7199            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
7200            (score > 0).then_some((score, candidate))
7201        })
7202        .max_by(|(left_score, left_value), (right_score, right_value)| {
7203            left_score
7204                .cmp(right_score)
7205                .then_with(|| right_value.len().cmp(&left_value.len()))
7206                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
7207        })
7208        .map(|(_, candidate)| candidate)
7209}
7210
7211fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
7212    let (field, value) = criteria.split_once(": ")?;
7213    Some((field, value))
7214}
7215
7216fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
7217    let versions = item
7218        .get("Versions")
7219        .and_then(JsonValue::as_array)
7220        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
7221    let mut result = Vec::new();
7222    for version in versions {
7223        let value = json_string(version, "PackageVersion")
7224            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
7225        let channel = json_string(version, "Channel").unwrap_or_default();
7226        result.push(VersionKey {
7227            version: value,
7228            channel,
7229        });
7230    }
7231    Ok(result)
7232}
7233
7234fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
7235    let mut merged = YamlMapping::new();
7236    let mut documents = Vec::new();
7237    for document in serde_yaml::Deserializer::from_slice(bytes) {
7238        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
7239        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
7240        if let Some(mapping) = value.as_mapping() {
7241            merge_yaml_mapping(&mut merged, mapping);
7242        }
7243    }
7244
7245    let id = yaml_string_from_root(&merged, "PackageIdentifier")
7246        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
7247    let version =
7248        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
7249    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
7250
7251    let installers = parse_yaml_installers(&merged);
7252    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
7253    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7254
7255    Ok((
7256        Manifest {
7257            id,
7258            name,
7259            version,
7260            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
7261            publisher: yaml_localized_string(&merged, "Publisher"),
7262            description: yaml_localized_string(&merged, "Description")
7263                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
7264            moniker: yaml_string_from_root(&merged, "Moniker"),
7265            package_url: yaml_localized_string(&merged, "PackageUrl"),
7266            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
7267            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
7268            license: yaml_localized_string(&merged, "License"),
7269            license_url: yaml_localized_string(&merged, "LicenseUrl"),
7270            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
7271            author: yaml_localized_string(&merged, "Author"),
7272            copyright: yaml_localized_string(&merged, "Copyright"),
7273            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
7274            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
7275            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
7276            tags: yaml_string_list(&merged, "Tags"),
7277            agreements: yaml_agreement_list(&merged),
7278            package_dependencies: yaml_package_dependencies(&merged),
7279            documentation: yaml_documentation_list(&merged),
7280            icons: yaml_icon_list(&merged),
7281            installers,
7282            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7283        },
7284        collapse_structured_document(&JsonValue::Array(documents)),
7285    ))
7286}
7287
7288fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
7289    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
7290}
7291
7292fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
7293    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
7294    let data = root
7295        .get("Data")
7296        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
7297    let versions = data
7298        .get("Versions")
7299        .and_then(JsonValue::as_array)
7300        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
7301    let selected = versions
7302        .iter()
7303        .find(|item| {
7304            json_string(item, "PackageVersion").as_deref() == Some(version)
7305                && json_string(item, "Channel").unwrap_or_default() == channel
7306        })
7307        .or_else(|| versions.first())
7308        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
7309
7310    let default_locale = selected
7311        .get("DefaultLocale")
7312        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
7313    let package_default_locale = data.get("DefaultLocale");
7314    let name = json_string(default_locale, "PackageName")
7315        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
7316    let installer_switch_defaults = json_installer_switches(selected);
7317    let top_platforms = json_string_list(selected, "Platform");
7318    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
7319    let top_installer_type = json_string(selected, "InstallerType");
7320    let top_nested_installer_type = json_string(selected, "NestedInstallerType");
7321    let top_nested_installer_files = json_nested_installer_files(selected);
7322
7323    let installers = selected
7324        .get("Installers")
7325        .and_then(JsonValue::as_array)
7326        .map(|items| {
7327            items
7328                .iter()
7329                .map(|item| Installer {
7330                    platforms: {
7331                        let platforms = json_string_list(item, "Platform");
7332                        if platforms.is_empty() {
7333                            top_platforms.clone()
7334                        } else {
7335                            platforms
7336                        }
7337                    },
7338                    minimum_os_version: json_string(item, "MinimumOSVersion")
7339                        .or_else(|| top_minimum_os_version.clone()),
7340                    architecture: json_string(item, "Architecture"),
7341                    installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
7342                    nested_installer_type: json_string(item, "NestedInstallerType")
7343                        .or_else(|| top_nested_installer_type.clone()),
7344                    nested_installer_files: {
7345                        let files = json_nested_installer_files(item);
7346                        if files.is_empty() {
7347                            top_nested_installer_files.clone()
7348                        } else {
7349                            files
7350                        }
7351                    },
7352                    url: json_string(item, "InstallerUrl"),
7353                    sha256: json_string(item, "InstallerSha256"),
7354                    product_code: json_string(item, "ProductCode"),
7355                    locale: json_string(item, "InstallerLocale"),
7356                    scope: json_string(item, "Scope"),
7357                    release_date: json_string(item, "ReleaseDate"),
7358                    package_family_name: json_string(item, "PackageFamilyName"),
7359                    upgrade_code: json_string(item, "UpgradeCode"),
7360                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
7361                    commands: json_string_list(item, "Commands"),
7362                    package_dependencies: json_package_dependencies(item),
7363                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
7364                })
7365                .collect::<Vec<_>>()
7366        })
7367        .unwrap_or_default();
7368    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
7369    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7370    let mut icons = json_icon_list(default_locale);
7371    if icons.is_empty() {
7372        icons = package_default_locale.map(json_icon_list).unwrap_or_default();
7373    }
7374
7375    let manifest = Manifest {
7376        id: package_id.to_owned(),
7377        name,
7378        version: version.to_owned(),
7379        channel: channel.to_owned(),
7380        publisher: json_string(default_locale, "Publisher"),
7381        description: json_string(default_locale, "Description")
7382            .or_else(|| json_string(default_locale, "ShortDescription")),
7383        moniker: json_string(default_locale, "Moniker"),
7384        package_url: json_string(default_locale, "PackageUrl"),
7385        publisher_url: json_string(default_locale, "PublisherUrl"),
7386        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
7387        license: json_string(default_locale, "License"),
7388        license_url: json_string(default_locale, "LicenseUrl"),
7389        privacy_url: json_string(default_locale, "PrivacyUrl"),
7390        author: json_string(default_locale, "Author"),
7391        copyright: json_string(default_locale, "Copyright"),
7392        copyright_url: json_string(default_locale, "CopyrightUrl"),
7393        release_notes: json_string(default_locale, "ReleaseNotes"),
7394        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
7395        tags: json_string_list(default_locale, "Tags"),
7396        agreements: json_agreement_list(default_locale),
7397        package_dependencies: json_package_dependencies(selected),
7398        documentation: json_documentation_list(default_locale),
7399        icons,
7400        installers,
7401        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7402    };
7403
7404    Ok((
7405        manifest,
7406        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
7407    ))
7408}
7409
7410fn collapse_structured_document(document: &JsonValue) -> JsonValue {
7411    match document {
7412        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
7413            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
7414        }
7415        JsonValue::Array(documents) => merge_manifest_documents(documents),
7416        JsonValue::Object(_) if is_merged_manifest_document(document) => {
7417            merge_manifest_documents(&split_merged_manifest_document(document))
7418        }
7419        _ => document.clone(),
7420    }
7421}
7422
7423fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
7424    documents.iter().map(collapse_structured_document).collect()
7425}
7426
7427fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
7428    let Some(root) = document.as_object_mut() else {
7429        return document;
7430    };
7431
7432    root.insert(
7433        "IconUrl".to_owned(),
7434        manifest
7435            .icon_url()
7436            .map(|url| JsonValue::String(url.to_owned()))
7437            .unwrap_or(JsonValue::Null),
7438    );
7439    root.insert(
7440        "Icon".to_owned(),
7441        manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
7442    );
7443    root.entry("Icons".to_owned())
7444        .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
7445
7446    document
7447}
7448
7449fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
7450    serde_json::to_value(icon).expect("package icon serialization cannot fail")
7451}
7452
7453fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
7454    let version = documents.iter().find(|document| {
7455        document
7456            .get("ManifestType")
7457            .and_then(JsonValue::as_str)
7458            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
7459    });
7460    let default_locale = documents.iter().find(|document| {
7461        document
7462            .get("ManifestType")
7463            .and_then(JsonValue::as_str)
7464            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
7465    });
7466    let installer = documents.iter().find(|document| {
7467        document
7468            .get("ManifestType")
7469            .and_then(JsonValue::as_str)
7470            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
7471    });
7472
7473    let mut singleton = JsonMap::new();
7474    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
7475    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
7476    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
7477
7478    if !singleton.contains_key("PackageLocale")
7479        && let Some(package_locale) = default_locale
7480            .and_then(|document| json_string(document, "PackageLocale"))
7481            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
7482    {
7483        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7484    }
7485
7486    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
7487    singleton.insert(
7488        "ManifestVersion".to_owned(),
7489        JsonValue::String(
7490            installer
7491                .and_then(|document| json_string(document, "ManifestVersion"))
7492                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
7493                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
7494                .unwrap_or_else(|| "1.10.0".to_owned()),
7495        ),
7496    );
7497    singleton.remove("DefaultLocale");
7498
7499    JsonValue::Object(singleton)
7500}
7501
7502fn is_merged_manifest_document(document: &JsonValue) -> bool {
7503    document
7504        .get("ManifestType")
7505        .and_then(JsonValue::as_str)
7506        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
7507}
7508
7509fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
7510    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
7511    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
7512    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7513    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
7514
7515    let version_document = serde_json::json!({
7516        "PackageIdentifier": package_identifier,
7517        "PackageVersion": package_version,
7518        "DefaultLocale": package_locale,
7519        "ManifestType": "version",
7520        "ManifestVersion": manifest_version,
7521    });
7522
7523    let mut default_locale_document = project_manifest_document(
7524        merged,
7525        &[
7526            "PackageIdentifier",
7527            "PackageVersion",
7528            "PackageLocale",
7529            "Publisher",
7530            "PublisherUrl",
7531            "PublisherSupportUrl",
7532            "PrivacyUrl",
7533            "Author",
7534            "PackageName",
7535            "PackageUrl",
7536            "License",
7537            "LicenseUrl",
7538            "Copyright",
7539            "CopyrightUrl",
7540            "ShortDescription",
7541            "Description",
7542            "Moniker",
7543            "Tags",
7544            "Agreements",
7545            "ReleaseNotes",
7546            "ReleaseNotesUrl",
7547            "PurchaseUrl",
7548            "InstallationNotes",
7549            "Documentations",
7550            "Icons",
7551        ],
7552    );
7553    default_locale_document.insert(
7554        "PackageIdentifier".to_owned(),
7555        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7556    );
7557    default_locale_document.insert(
7558        "PackageVersion".to_owned(),
7559        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7560    );
7561    default_locale_document.insert(
7562        "PackageLocale".to_owned(),
7563        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
7564    );
7565    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7566    default_locale_document.insert(
7567        "ManifestVersion".to_owned(),
7568        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7569    );
7570
7571    let mut installer_document = project_manifest_document(
7572        merged,
7573        &[
7574            "PackageIdentifier",
7575            "PackageVersion",
7576            "Channel",
7577            "InstallerLocale",
7578            "Platform",
7579            "MinimumOSVersion",
7580            "InstallerType",
7581            "NestedInstallerType",
7582            "NestedInstallerFiles",
7583            "Scope",
7584            "InstallModes",
7585            "InstallerSwitches",
7586            "InstallerSuccessCodes",
7587            "ExpectedReturnCodes",
7588            "UpgradeBehavior",
7589            "Commands",
7590            "Protocols",
7591            "FileExtensions",
7592            "Dependencies",
7593            "PackageFamilyName",
7594            "ProductCode",
7595            "Capabilities",
7596            "RestrictedCapabilities",
7597            "Markets",
7598            "InstallerAbortsTerminal",
7599            "ReleaseDate",
7600            "InstallLocationRequired",
7601            "RequireExplicitUpgrade",
7602            "DisplayInstallWarnings",
7603            "UnsupportedOSArchitectures",
7604            "UnsupportedArguments",
7605            "AppsAndFeaturesEntries",
7606            "ElevationRequirement",
7607            "InstallationMetadata",
7608            "DownloadCommandProhibited",
7609            "RepairBehavior",
7610            "ArchiveBinariesDependOnPath",
7611            "Authentication",
7612            "Installers",
7613        ],
7614    );
7615    installer_document.insert(
7616        "PackageIdentifier".to_owned(),
7617        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7618    );
7619    installer_document.insert(
7620        "PackageVersion".to_owned(),
7621        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7622    );
7623    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7624    installer_document.insert(
7625        "ManifestVersion".to_owned(),
7626        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7627    );
7628
7629    vec![
7630        version_document,
7631        JsonValue::Object(default_locale_document),
7632        JsonValue::Object(installer_document),
7633    ]
7634}
7635
7636fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
7637    let mut result = JsonMap::new();
7638    if let Some(source) = source.as_object() {
7639        for key in keys {
7640            if let Some(value) = source.get(*key) {
7641                result.insert((*key).to_owned(), value.clone());
7642            }
7643        }
7644    }
7645
7646    result
7647}
7648
7649fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
7650    if let Some(source) = source.and_then(JsonValue::as_object) {
7651        for key in keys {
7652            if let Some(value) = source.get(*key) {
7653                target.insert((*key).to_owned(), value.clone());
7654            }
7655        }
7656    }
7657}
7658
7659fn copy_all_manifest_keys_except(
7660    target: &mut JsonMap<String, JsonValue>,
7661    source: Option<&JsonValue>,
7662    excluded_keys: &[&str],
7663) {
7664    if let Some(source) = source.and_then(JsonValue::as_object) {
7665        for (key, value) in source {
7666            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
7667                target.insert(key.clone(), value.clone());
7668            }
7669        }
7670    }
7671}
7672
7673fn build_rest_manifest_documents(
7674    data: &JsonValue,
7675    installer_source: &JsonValue,
7676    default_locale: &JsonValue,
7677    package_id: &str,
7678    version: &str,
7679    channel: &str,
7680) -> JsonValue {
7681    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
7682    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7683    let manifest_version = json_string(installer_source, "ManifestVersion")
7684        .or_else(|| json_string(default_locale, "ManifestVersion"))
7685        .or_else(|| json_string(data, "ManifestVersion"))
7686        .unwrap_or_else(|| "1.10.0".to_owned());
7687
7688    let mut version_document = JsonMap::new();
7689    version_document.insert(
7690        "PackageIdentifier".to_owned(),
7691        JsonValue::String(package_identifier.clone()),
7692    );
7693    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7694    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
7695    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
7696    version_document.insert(
7697        "ManifestVersion".to_owned(),
7698        JsonValue::String(manifest_version.clone()),
7699    );
7700
7701    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
7702    default_locale_document.insert(
7703        "PackageIdentifier".to_owned(),
7704        JsonValue::String(package_identifier.clone()),
7705    );
7706    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7707    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7708    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7709    default_locale_document.insert(
7710        "ManifestVersion".to_owned(),
7711        JsonValue::String(manifest_version.clone()),
7712    );
7713
7714    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
7715    installer_document.remove("DefaultLocale");
7716    installer_document.remove("Locales");
7717    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
7718    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7719    if !channel.is_empty() {
7720        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
7721    }
7722    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7723    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
7724
7725    let documents = vec![
7726        JsonValue::Object(version_document),
7727        JsonValue::Object(default_locale_document),
7728        JsonValue::Object(installer_document),
7729    ];
7730
7731    merge_manifest_documents(&documents)
7732}
7733
7734fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
7735    let base = installer_defaults(root);
7736    let base_switches = yaml_installer_switches(root);
7737    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
7738        let installers = items
7739            .iter()
7740            .filter_map(YamlValue::as_mapping)
7741            .map(|item| {
7742                let mut merged = base.clone();
7743                merge_yaml_mapping(&mut merged, item);
7744                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
7745                installer_from_yaml(&merged, switches)
7746            })
7747            .collect::<Vec<_>>();
7748        if !installers.is_empty() {
7749            return installers;
7750        }
7751    }
7752
7753    if base.is_empty() {
7754        Vec::new()
7755    } else {
7756        vec![installer_from_yaml(&base, base_switches)]
7757    }
7758}
7759
7760fn installer_defaults(root: &YamlMapping) -> YamlMapping {
7761    let keys = [
7762        "Architecture",
7763        "InstallerType",
7764        "NestedInstallerType",
7765        "NestedInstallerFiles",
7766        "InstallerUrl",
7767        "InstallerSha256",
7768        "ProductCode",
7769        "InstallerLocale",
7770        "Platform",
7771        "MinimumOSVersion",
7772        "Scope",
7773        "ReleaseDate",
7774        "PackageFamilyName",
7775        "UpgradeCode",
7776        "Commands",
7777    ];
7778    let mut defaults = YamlMapping::new();
7779    for key in keys {
7780        if let Some(value) = root.get(YamlValue::from(key)) {
7781            defaults.insert(YamlValue::from(key), value.clone());
7782        }
7783    }
7784    defaults
7785}
7786
7787fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
7788    Installer {
7789        architecture: yaml_string(root, "Architecture"),
7790        installer_type: yaml_string(root, "InstallerType"),
7791        nested_installer_type: yaml_string(root, "NestedInstallerType"),
7792        nested_installer_files: yaml_nested_installer_files(root),
7793        url: yaml_string(root, "InstallerUrl"),
7794        sha256: yaml_string(root, "InstallerSha256"),
7795        product_code: yaml_string(root, "ProductCode"),
7796        locale: yaml_string(root, "InstallerLocale"),
7797        scope: yaml_string(root, "Scope"),
7798        release_date: yaml_string(root, "ReleaseDate"),
7799        package_family_name: yaml_string(root, "PackageFamilyName"),
7800        upgrade_code: yaml_string(root, "UpgradeCode"),
7801        platforms: yaml_string_list(root, "Platform"),
7802        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
7803        switches,
7804        commands: yaml_string_list(root, "Commands"),
7805        package_dependencies: yaml_package_dependencies(root),
7806        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
7807    }
7808}
7809
7810/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
7811/// the legacy `True`/`False` variants the winget catalog mixes in.
7812fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
7813    let Some(value) = root.get(YamlValue::from(key)) else {
7814        return false;
7815    };
7816    match value {
7817        YamlValue::Bool(b) => *b,
7818        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
7819        _ => false,
7820    }
7821}
7822
7823fn json_bool(value: &JsonValue, key: &str) -> bool {
7824    match value.get(key) {
7825        Some(JsonValue::Bool(b)) => *b,
7826        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
7827        _ => false,
7828    }
7829}
7830
7831fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
7832    let mapping = root
7833        .get(YamlValue::from("InstallerSwitches"))
7834        .or_else(|| root.get(YamlValue::from("Switches")))
7835        .and_then(YamlValue::as_mapping);
7836    yaml_installer_switches_from_mapping(mapping)
7837}
7838
7839fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
7840    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
7841
7842    InstallerSwitches {
7843        silent: string("Silent"),
7844        silent_with_progress: string("SilentWithProgress"),
7845        interactive: string("Interactive"),
7846        custom: string("Custom"),
7847        log: string("Log"),
7848        install_location: string("InstallLocation"),
7849    }
7850}
7851
7852fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
7853    yaml_string(root, key).or_else(|| {
7854        root.get(YamlValue::from("DefaultLocale"))
7855            .and_then(YamlValue::as_mapping)
7856            .and_then(|mapping| yaml_string(mapping, key))
7857    })
7858}
7859
7860fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
7861    yaml_string(root, key)
7862}
7863
7864fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
7865    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
7866}
7867
7868fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
7869    match value {
7870        YamlValue::Null => None,
7871        YamlValue::Bool(value) => Some(value.to_string()),
7872        YamlValue::Number(value) => Some(value.to_string()),
7873        YamlValue::String(value) => Some(value.clone()),
7874        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
7875        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
7876    }
7877}
7878
7879fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
7880    root.get(YamlValue::from(key))
7881        .and_then(YamlValue::as_sequence)
7882        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
7883        .unwrap_or_default()
7884}
7885
7886fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
7887    root.get(YamlValue::from("Documentations"))
7888        .and_then(YamlValue::as_sequence)
7889        .map(|items| {
7890            items
7891                .iter()
7892                .filter_map(YamlValue::as_mapping)
7893                .filter_map(|item| {
7894                    let url = yaml_string(item, "DocumentUrl")?;
7895                    Some(Documentation {
7896                        label: yaml_string(item, "DocumentLabel"),
7897                        url,
7898                    })
7899                })
7900                .collect()
7901        })
7902        .unwrap_or_default()
7903}
7904
7905fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
7906    root.get(YamlValue::from("Icons"))
7907        .and_then(YamlValue::as_sequence)
7908        .map(|items| {
7909            items
7910                .iter()
7911                .filter_map(YamlValue::as_mapping)
7912                .filter_map(|item| {
7913                    let icon = PackageIcon {
7914                        icon_url: yaml_string(item, "IconUrl"),
7915                        icon_file_type: yaml_string(item, "IconFileType"),
7916                        icon_resolution: yaml_string(item, "IconResolution"),
7917                        icon_theme: yaml_string(item, "IconTheme"),
7918                        icon_sha256: yaml_string(item, "IconSha256"),
7919                    };
7920                    (!icon.is_empty()).then_some(icon)
7921                })
7922                .collect()
7923        })
7924        .unwrap_or_default()
7925}
7926
7927fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
7928    root.get(YamlValue::from("Agreements"))
7929        .and_then(YamlValue::as_sequence)
7930        .map(|items| {
7931            items
7932                .iter()
7933                .filter_map(YamlValue::as_mapping)
7934                .map(|item| PackageAgreement {
7935                    label: yaml_string(item, "AgreementLabel"),
7936                    text: yaml_string(item, "Agreement"),
7937                    url: yaml_string(item, "AgreementUrl"),
7938                })
7939                .collect()
7940        })
7941        .unwrap_or_default()
7942}
7943
7944fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
7945    root.get(YamlValue::from("Dependencies"))
7946        .and_then(YamlValue::as_mapping)
7947        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
7948        .and_then(YamlValue::as_sequence)
7949        .map(|items| {
7950            items
7951                .iter()
7952                .filter_map(YamlValue::as_mapping)
7953                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
7954                .collect()
7955        })
7956        .unwrap_or_default()
7957}
7958
7959fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
7960    root.get(YamlValue::from("NestedInstallerFiles"))
7961        .and_then(YamlValue::as_sequence)
7962        .map(|items| {
7963            items
7964                .iter()
7965                .filter_map(YamlValue::as_mapping)
7966                .filter_map(|item| {
7967                    let relative = yaml_string(item, "RelativeFilePath")?;
7968                    Some(NestedInstallerFile {
7969                        relative_file_path: relative,
7970                        portable_command_alias: yaml_string(item, "PortableCommandAlias"),
7971                    })
7972                })
7973                .collect()
7974        })
7975        .unwrap_or_default()
7976}
7977
7978fn json_string(value: &JsonValue, key: &str) -> Option<String> {
7979    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
7980}
7981
7982fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
7983    value
7984        .get(key)
7985        .and_then(JsonValue::as_array)
7986        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7987        .unwrap_or_default()
7988}
7989
7990fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7991    value
7992        .get("Documentations")
7993        .and_then(JsonValue::as_array)
7994        .map(|items| {
7995            items
7996                .iter()
7997                .filter_map(|item| {
7998                    let url = json_string(item, "DocumentUrl")?;
7999                    Some(Documentation {
8000                        label: json_string(item, "DocumentLabel"),
8001                        url,
8002                    })
8003                })
8004                .collect()
8005        })
8006        .unwrap_or_default()
8007}
8008
8009fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
8010    value
8011        .get("Icons")
8012        .and_then(JsonValue::as_array)
8013        .map(|items| {
8014            items
8015                .iter()
8016                .filter_map(|item| {
8017                    let icon = PackageIcon {
8018                        icon_url: json_string(item, "IconUrl"),
8019                        icon_file_type: json_string(item, "IconFileType"),
8020                        icon_resolution: json_string(item, "IconResolution"),
8021                        icon_theme: json_string(item, "IconTheme"),
8022                        icon_sha256: json_string(item, "IconSha256"),
8023                    };
8024                    (!icon.is_empty()).then_some(icon)
8025                })
8026                .collect()
8027        })
8028        .unwrap_or_default()
8029}
8030
8031fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
8032    value
8033        .get("Agreements")
8034        .and_then(JsonValue::as_array)
8035        .map(|items| {
8036            items
8037                .iter()
8038                .map(|item| PackageAgreement {
8039                    label: json_string(item, "AgreementLabel"),
8040                    text: json_string(item, "Agreement"),
8041                    url: json_string(item, "AgreementUrl"),
8042                })
8043                .collect()
8044        })
8045        .unwrap_or_default()
8046}
8047
8048fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
8049    value
8050        .get("Dependencies")
8051        .and_then(JsonValue::as_object)
8052        .and_then(|deps| deps.get("PackageDependencies"))
8053        .and_then(JsonValue::as_array)
8054        .map(|items| {
8055            items
8056                .iter()
8057                .filter_map(|item| json_string(item, "PackageIdentifier"))
8058                .collect()
8059        })
8060        .unwrap_or_default()
8061}
8062
8063fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
8064    value
8065        .get("NestedInstallerFiles")
8066        .and_then(JsonValue::as_array)
8067        .map(|items| {
8068            items
8069                .iter()
8070                .filter_map(|item| {
8071                    let relative = json_string(item, "RelativeFilePath")?;
8072                    Some(NestedInstallerFile {
8073                        relative_file_path: relative,
8074                        portable_command_alias: json_string(item, "PortableCommandAlias"),
8075                    })
8076                })
8077                .collect()
8078        })
8079        .unwrap_or_default()
8080}
8081
8082fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
8083    let switches = value
8084        .get("InstallerSwitches")
8085        .or_else(|| value.get("Switches"))
8086        .and_then(JsonValue::as_object);
8087
8088    let string = |key: &str| {
8089        switches
8090            .and_then(|switches| switches.get(key))
8091            .and_then(JsonValue::as_str)
8092            .map(str::to_owned)
8093    };
8094
8095    InstallerSwitches {
8096        silent: string("Silent"),
8097        silent_with_progress: string("SilentWithProgress"),
8098        interactive: string("Interactive"),
8099        custom: string("Custom"),
8100        log: string("Log"),
8101        install_location: string("InstallLocation"),
8102    }
8103}
8104
8105fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
8106    for (key, value) in source {
8107        target.insert(key.clone(), value.clone());
8108    }
8109}
8110
8111fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
8112    let reader = Cursor::new(bytes.to_vec());
8113    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
8114    let mut file = archive
8115        .by_name(member_name)
8116        .with_context(|| format!("zip payload missing {member_name}"))?;
8117    let mut output = Vec::new();
8118    file.read_to_end(&mut output)
8119        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
8120    Ok(output)
8121}
8122
8123fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
8124    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
8125        return Ok(output);
8126    }
8127
8128    if !bytes.starts_with(b"CK")
8129        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
8130    {
8131        return decompress_mszyml_payload(&bytes[offset + 2..]);
8132    }
8133
8134    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
8135}
8136
8137fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
8138    let mut decoder = flate2::read::DeflateDecoder::new(payload);
8139    let mut output = String::new();
8140    decoder.read_to_string(&mut output)?;
8141    Ok(output)
8142}
8143
8144fn cache_control_max_age(response: &Response) -> u64 {
8145    response
8146        .headers()
8147        .get(reqwest::header::CACHE_CONTROL)
8148        .and_then(|value| value.to_str().ok())
8149        .and_then(|header| {
8150            header.split(',').find_map(|part| {
8151                let trimmed = part.trim();
8152                trimmed
8153                    .strip_prefix("max-age=")
8154                    .and_then(|value| value.parse::<u64>().ok())
8155            })
8156        })
8157        .unwrap_or(60)
8158}
8159
8160fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
8161    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
8162        let (major, minor) = major_minor(candidate);
8163        server_versions.iter().any(|version| {
8164            let (server_major, server_minor) = major_minor(version);
8165            server_major == major && server_minor == minor
8166        })
8167    })
8168}
8169
8170fn major_minor(version: &str) -> (String, String) {
8171    let mut parts = version.split('.');
8172    (
8173        parts.next().unwrap_or_default().to_owned(),
8174        parts.next().unwrap_or_default().to_owned(),
8175    )
8176}
8177
8178fn default_market() -> String {
8179    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
8180}
8181
8182fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
8183    let requested_locale = query.locale.as_deref();
8184    let requested_architecture = query.installer_architecture.as_deref();
8185    let requested_type = query.installer_type.as_deref();
8186    let requested_scope = query.install_scope.as_deref();
8187    let requested_platform = query.platform.as_deref();
8188    let requested_os_version = query.os_version.as_deref();
8189    let system_architecture = current_architecture();
8190    let system_platform = current_platform();
8191    let current_os_version = current_os_version();
8192
8193    installers
8194        .iter()
8195        .enumerate()
8196        .filter(|(_, installer)| {
8197            installer_matches_requested(
8198                installer,
8199                requested_type,
8200                requested_scope,
8201                requested_platform,
8202                requested_os_version,
8203                system_platform,
8204                current_os_version.as_deref(),
8205            )
8206        })
8207        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
8208        .max_by_key(|(index, installer)| {
8209            (
8210                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
8211                std::cmp::Reverse(*index),
8212            )
8213        })
8214        .map(|(_, installer)| installer)
8215        .cloned()
8216}
8217
8218fn installer_matches_requested(
8219    installer: &Installer,
8220    requested_type: Option<&str>,
8221    requested_scope: Option<&str>,
8222    requested_platform: Option<&str>,
8223    requested_os_version: Option<&str>,
8224    system_platform: Option<&str>,
8225    current_os_version: Option<&str>,
8226) -> bool {
8227    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
8228        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
8229        && installer_matches_platform(installer, requested_platform, system_platform)
8230        && installer_matches_os_version(installer, requested_os_version, current_os_version)
8231}
8232
8233fn installer_matches_platform(
8234    installer: &Installer,
8235    requested_platform: Option<&str>,
8236    system_platform: Option<&str>,
8237) -> bool {
8238    if installer.platforms.is_empty() {
8239        return true;
8240    }
8241
8242    let Some(effective_platform) = requested_platform.or(system_platform) else {
8243        return true;
8244    };
8245
8246    installer
8247        .platforms
8248        .iter()
8249        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
8250}
8251
8252fn installer_matches_os_version(
8253    installer: &Installer,
8254    requested_os_version: Option<&str>,
8255    current_os_version: Option<&str>,
8256) -> bool {
8257    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
8258        return true;
8259    };
8260
8261    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
8262        return true;
8263    };
8264
8265    match (
8266        parse_dotted_version(minimum_os_version),
8267        parse_dotted_version(actual_os_version),
8268    ) {
8269        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
8270        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
8271    }
8272}
8273
8274fn installer_matches_architecture(
8275    installer: &Installer,
8276    requested_architecture: Option<&str>,
8277    system_architecture: &str,
8278) -> bool {
8279    let Some(architecture) = installer.architecture.as_deref() else {
8280        return true;
8281    };
8282
8283    let architecture = architecture.to_ascii_lowercase();
8284    if let Some(requested) = requested_architecture {
8285        return architecture.eq_ignore_ascii_case(requested);
8286    }
8287
8288    preferred_architectures(system_architecture)
8289        .iter()
8290        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
8291}
8292
8293fn installer_rank(
8294    installer: &Installer,
8295    requested_locale: Option<&str>,
8296    requested_architecture: Option<&str>,
8297    system_architecture: &str,
8298) -> (i32, i32, i32) {
8299    let architecture_rank = architecture_rank(
8300        installer.architecture.as_deref(),
8301        requested_architecture.unwrap_or(system_architecture),
8302        requested_architecture.is_some(),
8303        system_architecture,
8304    );
8305    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
8306    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
8307    (architecture_rank, locale_rank, command_rank)
8308}
8309
8310fn architecture_rank(
8311    installer_architecture: Option<&str>,
8312    preferred_architecture: &str,
8313    strict: bool,
8314    system_architecture: &str,
8315) -> i32 {
8316    let Some(value) = installer_architecture else {
8317        return 0;
8318    };
8319    if value.eq_ignore_ascii_case(preferred_architecture) {
8320        return 5;
8321    }
8322    if value.eq_ignore_ascii_case("neutral") {
8323        return 4;
8324    }
8325    if strict {
8326        return -1;
8327    }
8328
8329    preferred_architectures(system_architecture)
8330        .iter()
8331        .rev()
8332        .position(|candidate| value.eq_ignore_ascii_case(candidate))
8333        .and_then(|index| i32::try_from(index).ok())
8334        .and_then(|index| index.checked_add(1))
8335        .unwrap_or(-1)
8336}
8337
8338/// True when at least one installer in `manifest.installers` targets an
8339/// architecture the host can run. winget hides upgrades whose latest
8340/// version publishes no compatible installer.
8341fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
8342    if manifest.installers.is_empty() {
8343        return true;
8344    }
8345    let allowed = preferred_architectures(current_architecture());
8346    manifest.installers.iter().any(|installer| {
8347        let arch = installer.architecture.as_deref().unwrap_or("neutral");
8348        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
8349    })
8350}
8351
8352fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
8353    match system_architecture {
8354        "arm64" => &["arm64", "neutral", "x64", "x86"],
8355        "x64" => &["x64", "neutral", "x86"],
8356        "x86" => &["x86", "neutral"],
8357        _ => &["neutral"],
8358    }
8359}
8360
8361fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
8362    match (installer_locale, requested_locale) {
8363        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
8364        (Some(installer), Some(requested))
8365            if installer
8366                .split('-')
8367                .next()
8368                .zip(requested.split('-').next())
8369                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
8370        {
8371            2
8372        }
8373        (None, Some(_)) => 1,
8374        (Some(_), None) => 1,
8375        (None, None) => 0,
8376        _ => -1,
8377    }
8378}
8379
8380fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
8381    match requested {
8382        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
8383        None => true,
8384    }
8385}
8386
8387#[cfg(windows)]
8388fn current_architecture() -> &'static str {
8389    use std::sync::OnceLock;
8390    static CELL: OnceLock<&'static str> = OnceLock::new();
8391    CELL.get_or_init(|| {
8392        // IsWow64Process2's `native_machine` is the OS architecture even
8393        // when the process is running under emulation (x64 binary on
8394        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
8395        // mis-report the host arch in that case.
8396        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
8397        let mut process_machine: u16 = 0;
8398        let mut native_machine: u16 = 0;
8399        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
8400        // both out-pointers reference local stack u16s that outlive the call.
8401        let handle = unsafe { GetCurrentProcess() };
8402        // SAFETY: handle is valid per above; pointers are valid.
8403        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
8404        if ok != 0 {
8405            match native_machine {
8406                0xAA64 => return "arm64",
8407                0x8664 => return "x64",
8408                0x014C => return "x86",
8409                _ => {}
8410            }
8411        }
8412        compiled_architecture()
8413    })
8414}
8415
8416#[cfg(not(windows))]
8417fn current_architecture() -> &'static str {
8418    compiled_architecture()
8419}
8420
8421fn compiled_architecture() -> &'static str {
8422    match std::env::consts::ARCH {
8423        "x86_64" => "x64",
8424        "x86" => "x86",
8425        "aarch64" => "arm64",
8426        _ => "neutral",
8427    }
8428}
8429
8430fn current_platform() -> Option<&'static str> {
8431    if cfg!(windows) { Some("Windows.Desktop") } else { None }
8432}
8433
8434#[cfg(windows)]
8435fn current_os_version() -> Option<String> {
8436    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8437    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
8438    let build: String = key.get_value("CurrentBuildNumber").ok()?;
8439    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
8440    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
8441    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
8442    Some(format!("{major}.{minor}.{build}.{ubr}"))
8443}
8444
8445#[cfg(not(windows))]
8446fn current_os_version() -> Option<String> {
8447    None
8448}
8449
8450fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
8451    let mut parsed = Vec::new();
8452    for component in version.split('.') {
8453        if component.is_empty() {
8454            continue;
8455        }
8456        parsed.push(component.parse().ok()?);
8457    }
8458
8459    if parsed.is_empty() { None } else { Some(parsed) }
8460}
8461
8462fn select_v1_version(
8463    versions: &[V1VersionRow],
8464    requested_version: Option<&str>,
8465    requested_channel: Option<&str>,
8466    package_id: &str,
8467    source_name: &str,
8468) -> Result<V1VersionRow> {
8469    let mut ordered = versions.to_vec();
8470    ordered.sort_by(|left, right| {
8471        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8472    });
8473
8474    if let Some(version) = requested_version {
8475        let channel = requested_channel.unwrap_or_default();
8476        return ordered
8477            .iter()
8478            .find(|candidate| {
8479                candidate.version.eq_ignore_ascii_case(version)
8480                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8481            })
8482            .cloned()
8483            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8484    }
8485
8486    if let Some(channel) = requested_channel {
8487        return ordered
8488            .iter()
8489            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8490            .cloned()
8491            .ok_or_else(|| {
8492                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8493            });
8494    }
8495
8496    ordered
8497        .into_iter()
8498        .next()
8499        .ok_or_else(|| anyhow!("package had no versions"))
8500}
8501
8502fn select_v2_version(
8503    versions: &[PackageVersionDataEntry],
8504    requested_version: Option<&str>,
8505    requested_channel: Option<&str>,
8506    package_id: &str,
8507    source_name: &str,
8508) -> Result<PackageVersionDataEntry> {
8509    let mut ordered = versions.to_vec();
8510    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
8511
8512    if let Some(channel) = requested_channel {
8513        bail!(
8514            "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."
8515        );
8516    }
8517
8518    if let Some(version) = requested_version {
8519        return ordered
8520            .iter()
8521            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
8522            .cloned()
8523            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8524    }
8525
8526    ordered
8527        .into_iter()
8528        .next()
8529        .ok_or_else(|| anyhow!("package had no V2 versions"))
8530}
8531
8532fn missing_package_version(
8533    package_id: &str,
8534    source_name: &str,
8535    requested_version: &str,
8536    requested_channel: Option<&str>,
8537) -> anyhow::Error {
8538    anyhow!(MissingPackageVersionError {
8539        package_id: package_id.to_owned(),
8540        source_name: source_name.to_owned(),
8541        requested_version: requested_version.to_owned(),
8542        requested_channel: requested_channel.map(str::to_owned),
8543    })
8544}
8545
8546fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
8547    if let Some(refresh_error) = refresh.error.as_deref() {
8548        let missing_error = error.to_string();
8549        return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
8550    }
8551
8552    Err(error)
8553}
8554
8555fn select_rest_version<'a>(
8556    versions: &'a [VersionKey],
8557    requested_version: Option<&str>,
8558    requested_channel: Option<&str>,
8559    package_id: &str,
8560    source_name: &str,
8561) -> Result<&'a VersionKey> {
8562    if let Some(version) = requested_version {
8563        let channel = requested_channel.unwrap_or_default();
8564        return versions
8565            .iter()
8566            .find(|candidate| {
8567                candidate.version.eq_ignore_ascii_case(version)
8568                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8569            })
8570            .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8571    }
8572
8573    if let Some(channel) = requested_channel {
8574        return versions
8575            .iter()
8576            .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8577            .ok_or_else(|| {
8578                anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8579            });
8580    }
8581
8582    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
8583}
8584
8585fn sort_versions_desc(versions: &mut [VersionKey]) {
8586    versions.sort_by(|left, right| {
8587        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8588    });
8589}
8590
8591fn compare_version_and_channel(
8592    left_version: &str,
8593    left_channel: &str,
8594    right_version: &str,
8595    right_channel: &str,
8596) -> Ordering {
8597    compare_version(left_version, right_version).then_with(|| {
8598        left_channel
8599            .to_ascii_lowercase()
8600            .cmp(&right_channel.to_ascii_lowercase())
8601    })
8602}
8603
8604fn compare_version(left: &str, right: &str) -> Ordering {
8605    let left_less_than = left.trim().strip_prefix('<').map(str::trim);
8606    let right_less_than = right.trim().strip_prefix('<').map(str::trim);
8607    match (left_less_than, right_less_than) {
8608        (Some(left_value), Some(right_value)) => return compare_version(left_value, right_value),
8609        (Some(left_value), None) if compare_version(left_value, right) == Ordering::Equal => return Ordering::Less,
8610        (None, Some(right_value)) if compare_version(left, right_value) == Ordering::Equal => return Ordering::Greater,
8611        _ => {}
8612    }
8613
8614    let left_parts = tokenize_version(left);
8615    let right_parts = tokenize_version(right);
8616    let max_len = left_parts.len().max(right_parts.len());
8617
8618    for index in 0..max_len {
8619        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
8620        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
8621        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
8622
8623        let ordering = if let Some((left_number, right_number)) = numeric {
8624            left_number.cmp(&right_number)
8625        } else {
8626            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
8627        };
8628
8629        if ordering != Ordering::Equal {
8630            return ordering;
8631        }
8632    }
8633
8634    Ordering::Equal
8635}
8636
8637fn tokenize_version(value: &str) -> Vec<String> {
8638    let mut parts = Vec::new();
8639    let mut current = String::new();
8640    let mut current_is_digit = None;
8641
8642    for ch in value.chars() {
8643        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
8644            if !current.is_empty() {
8645                parts.push(std::mem::take(&mut current));
8646            }
8647            current_is_digit = None;
8648            continue;
8649        }
8650
8651        let is_digit = ch.is_ascii_digit();
8652        match current_is_digit {
8653            Some(previous) if previous != is_digit => {
8654                parts.push(std::mem::take(&mut current));
8655                current.push(ch);
8656                current_is_digit = Some(is_digit);
8657            }
8658            Some(_) => current.push(ch),
8659            None => {
8660                current.push(ch);
8661                current_is_digit = Some(is_digit);
8662            }
8663        }
8664    }
8665
8666    if !current.is_empty() {
8667        parts.push(current);
8668    }
8669
8670    parts
8671}
8672
8673fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
8674    if !hash_matches(Some(expected_hash), bytes) {
8675        bail!("downloaded file hash did not match expected SHA256");
8676    }
8677    Ok(())
8678}
8679
8680#[cfg(test)]
8681fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
8682    let Some(expected_hash) = expected_hash else {
8683        return Ok(());
8684    };
8685    if ignore_security_hash {
8686        return Ok(());
8687    }
8688
8689    if !hash_matches(Some(expected_hash), bytes) {
8690        let actual = sha256_hex(bytes);
8691        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
8692    }
8693    Ok(())
8694}
8695
8696fn verify_installer_hash_hex(expected_hash: Option<&str>, actual_hash: &str, ignore_security_hash: bool) -> Result<()> {
8697    let Some(expected_hash) = expected_hash else {
8698        return Ok(());
8699    };
8700    if ignore_security_hash {
8701        return Ok(());
8702    }
8703
8704    if !actual_hash.eq_ignore_ascii_case(expected_hash) {
8705        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual_hash}");
8706    }
8707    Ok(())
8708}
8709
8710const INSTALLER_DOWNLOAD_ATTEMPTS: usize = 3;
8711const INSTALLER_DOWNLOAD_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(30);
8712const INSTALLER_DOWNLOAD_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(60);
8713const INSTALLER_DOWNLOAD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30 * 60);
8714const INSTALLER_DOWNLOAD_METADATA_INTERVAL: u64 = 4 * 1024 * 1024;
8715
8716#[derive(Debug, Clone, Serialize, Deserialize)]
8717struct InstallerDownloadMetadata {
8718    url: String,
8719    etag: Option<String>,
8720    last_modified: Option<String>,
8721    total_length: Option<u64>,
8722    offset: u64,
8723}
8724
8725#[derive(Debug)]
8726struct DownloadAttempt {
8727    total_length: Option<u64>,
8728}
8729
8730fn download_installer_to_file(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8731    let runtime = tokio::runtime::Builder::new_current_thread()
8732        .enable_all()
8733        .build()
8734        .context("failed to create installer download runtime")?;
8735    runtime.block_on(download_installer_to_file_async(user_agent, url, dest))
8736}
8737
8738async fn download_installer_to_file_async(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8739    let part_path = sidecar_path(dest, "part");
8740    let metadata_path = sidecar_path(dest, "part.json");
8741    let started_at = Instant::now();
8742    let mut last_error = None;
8743
8744    for _attempt in 1..=INSTALLER_DOWNLOAD_ATTEMPTS {
8745        if started_at.elapsed() >= INSTALLER_DOWNLOAD_TOTAL_TIMEOUT {
8746            break;
8747        }
8748
8749        let remaining = INSTALLER_DOWNLOAD_TOTAL_TIMEOUT
8750            .checked_sub(started_at.elapsed())
8751            .unwrap_or_else(|| StdDuration::from_secs(1));
8752        match try_download_installer_to_part(user_agent, url, &part_path, &metadata_path, remaining).await {
8753            Ok(()) => {
8754                let actual_hash = sha256_file_hex(&part_path)?;
8755                replace_file(&part_path, dest)?;
8756                let _ = fs::remove_file(&metadata_path);
8757                return Ok(actual_hash);
8758            }
8759            Err(error) => {
8760                last_error = Some(error);
8761            }
8762        }
8763    }
8764
8765    Err(last_error.unwrap_or_else(|| anyhow!("installer download timed out")))
8766}
8767
8768async fn try_download_installer_to_part(
8769    user_agent: &str,
8770    url: &str,
8771    part_path: &Path,
8772    metadata_path: &Path,
8773    remaining: StdDuration,
8774) -> Result<()> {
8775    let metadata = read_installer_download_metadata(metadata_path, part_path, url)?;
8776    let mut offset = metadata.as_ref().map_or(0, |metadata| metadata.offset);
8777    let mut request = reqwest::Client::builder()
8778        .user_agent(user_agent)
8779        .connect_timeout(INSTALLER_DOWNLOAD_CONNECT_TIMEOUT)
8780        .timeout(remaining)
8781        .build()
8782        .context("failed to build installer download client")?
8783        .get(url)
8784        .header(ACCEPT_ENCODING, "identity");
8785
8786    if offset > 0 {
8787        request = request.header(RANGE, format!("bytes={offset}-"));
8788        if let Some(if_range) = metadata.as_ref().and_then(installer_download_if_range) {
8789            request = request.header(IF_RANGE, if_range);
8790        }
8791    }
8792
8793    let mut response = request.send().await.context("failed to download installer")?;
8794    let headers = response.headers().clone();
8795    let status = response.status();
8796    let attempt = if status == StatusCode::PARTIAL_CONTENT && offset > 0 {
8797        parse_content_range(headers.get(CONTENT_RANGE), offset)?
8798    } else if status == StatusCode::OK {
8799        offset = 0;
8800        DownloadAttempt {
8801            total_length: content_length(&headers),
8802        }
8803    } else if status == StatusCode::RANGE_NOT_SATISFIABLE {
8804        let _ = fs::remove_file(part_path);
8805        let _ = fs::remove_file(metadata_path);
8806        bail!("installer partial download could not be resumed; retrying from the beginning");
8807    } else if status.is_success() {
8808        bail!("unexpected installer download HTTP status {status}");
8809    } else {
8810        bail!("Download failed: HTTP {status}");
8811    };
8812
8813    if offset == 0 {
8814        let _ = fs::remove_file(part_path);
8815    }
8816
8817    let mut file = if offset == 0 {
8818        fs::File::create(part_path).with_context(|| format!("failed to create {}", part_path.display()))?
8819    } else {
8820        OpenOptions::new()
8821            .append(true)
8822            .open(part_path)
8823            .with_context(|| format!("failed to open {}", part_path.display()))?
8824    };
8825
8826    let mut metadata = InstallerDownloadMetadata {
8827        url: url.to_owned(),
8828        etag: strong_etag(&headers),
8829        last_modified: header_string(&headers, LAST_MODIFIED),
8830        total_length: attempt.total_length,
8831        offset,
8832    };
8833    write_installer_download_metadata(metadata_path, &metadata)?;
8834
8835    let mut next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8836    while let Some(chunk) = tokio::time::timeout(INSTALLER_DOWNLOAD_IDLE_TIMEOUT, response.chunk())
8837        .await
8838        .context("installer download stalled while waiting for data")?
8839        .context("failed to read installer response body")?
8840    {
8841        file.write_all(&chunk)
8842            .with_context(|| format!("failed to write {}", part_path.display()))?;
8843        offset += u64::try_from(chunk.len()).expect("chunk length fits in u64");
8844
8845        if offset >= next_metadata_offset {
8846            file.flush()
8847                .with_context(|| format!("failed to flush {}", part_path.display()))?;
8848            metadata.offset = offset;
8849            write_installer_download_metadata(metadata_path, &metadata)?;
8850            next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8851        }
8852    }
8853
8854    file.flush()
8855        .with_context(|| format!("failed to flush {}", part_path.display()))?;
8856    file.sync_all()
8857        .with_context(|| format!("failed to sync {}", part_path.display()))?;
8858
8859    if let Some(total_length) = attempt.total_length
8860        && offset != total_length
8861    {
8862        bail!("installer download ended early: received {offset} of {total_length} bytes");
8863    }
8864
8865    metadata.offset = offset;
8866    metadata.total_length = attempt.total_length;
8867    write_installer_download_metadata(metadata_path, &metadata)?;
8868    Ok(())
8869}
8870
8871fn read_installer_download_metadata(
8872    metadata_path: &Path,
8873    part_path: &Path,
8874    url: &str,
8875) -> Result<Option<InstallerDownloadMetadata>> {
8876    let Some(mut metadata) = fs::read(metadata_path)
8877        .ok()
8878        .and_then(|bytes| serde_json::from_slice::<InstallerDownloadMetadata>(&bytes).ok())
8879    else {
8880        let _ = fs::remove_file(part_path);
8881        let _ = fs::remove_file(metadata_path);
8882        return Ok(None);
8883    };
8884
8885    if metadata.url != url || metadata.offset == 0 {
8886        let _ = fs::remove_file(part_path);
8887        let _ = fs::remove_file(metadata_path);
8888        return Ok(None);
8889    }
8890
8891    let part_len = fs::metadata(part_path).map(|metadata| metadata.len()).unwrap_or(0);
8892    metadata.offset = metadata.offset.min(part_len);
8893    if metadata.offset == 0 {
8894        let _ = fs::remove_file(part_path);
8895        let _ = fs::remove_file(metadata_path);
8896        return Ok(None);
8897    }
8898
8899    OpenOptions::new()
8900        .write(true)
8901        .open(part_path)
8902        .with_context(|| format!("failed to open {}", part_path.display()))?
8903        .set_len(metadata.offset)
8904        .with_context(|| format!("failed to truncate {}", part_path.display()))?;
8905
8906    Ok(Some(metadata))
8907}
8908
8909fn write_installer_download_metadata(path: &Path, metadata: &InstallerDownloadMetadata) -> Result<()> {
8910    let bytes = serde_json::to_vec(metadata).context("failed to serialize installer download metadata")?;
8911    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
8912}
8913
8914fn installer_download_if_range(metadata: &InstallerDownloadMetadata) -> Option<String> {
8915    metadata
8916        .etag
8917        .as_ref()
8918        .filter(|etag| !etag.starts_with("W/"))
8919        .cloned()
8920        .or_else(|| metadata.last_modified.clone())
8921}
8922
8923fn parse_content_range(value: Option<&HeaderValue>, expected_start: u64) -> Result<DownloadAttempt> {
8924    let value = value
8925        .and_then(|value| value.to_str().ok())
8926        .ok_or_else(|| anyhow!("partial installer response missing Content-Range"))?;
8927    let range = value
8928        .strip_prefix("bytes ")
8929        .ok_or_else(|| anyhow!("unsupported Content-Range value: {value}"))?;
8930    let (span, total) = range
8931        .split_once('/')
8932        .ok_or_else(|| anyhow!("invalid Content-Range value: {value}"))?;
8933    let (start, _) = span
8934        .split_once('-')
8935        .ok_or_else(|| anyhow!("invalid Content-Range byte span: {value}"))?;
8936    let start = start
8937        .parse::<u64>()
8938        .with_context(|| format!("invalid Content-Range start: {value}"))?;
8939    if start != expected_start {
8940        bail!("partial installer response started at {start}, expected {expected_start}");
8941    }
8942    let total_length = if total == "*" {
8943        None
8944    } else {
8945        Some(
8946            total
8947                .parse::<u64>()
8948                .with_context(|| format!("invalid Content-Range total: {value}"))?,
8949        )
8950    };
8951    Ok(DownloadAttempt { total_length })
8952}
8953
8954fn content_length(headers: &HeaderMap) -> Option<u64> {
8955    headers
8956        .get(CONTENT_LENGTH)
8957        .and_then(|value| value.to_str().ok())
8958        .and_then(|value| value.parse::<u64>().ok())
8959}
8960
8961fn strong_etag(headers: &HeaderMap) -> Option<String> {
8962    header_string(headers, ETAG).filter(|value| !value.starts_with("W/"))
8963}
8964
8965fn header_string(headers: &HeaderMap, name: reqwest::header::HeaderName) -> Option<String> {
8966    headers
8967        .get(name)
8968        .and_then(|value| value.to_str().ok())
8969        .map(str::to_owned)
8970}
8971
8972fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
8973    let mut value = path.as_os_str().to_os_string();
8974    value.push(".");
8975    value.push(suffix);
8976    PathBuf::from(value)
8977}
8978
8979fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
8980    expected_hash
8981        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
8982        .unwrap_or(true)
8983}
8984
8985fn sha256_hex(bytes: &[u8]) -> String {
8986    let digest = Sha256::digest(bytes);
8987    sha256_digest_hex(digest)
8988}
8989
8990fn sha256_file_hex(path: &Path) -> Result<String> {
8991    let mut file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
8992    let mut hasher = Sha256::new();
8993    let mut buffer = [0u8; 128 * 1024];
8994    loop {
8995        let read = file
8996            .read(&mut buffer)
8997            .with_context(|| format!("failed to read {}", path.display()))?;
8998        if read == 0 {
8999            break;
9000        }
9001        hasher.update(&buffer[..read]);
9002    }
9003    Ok(sha256_digest_hex(hasher.finalize()))
9004}
9005
9006fn sha256_digest_hex(digest: impl AsRef<[u8]>) -> String {
9007    let digest = digest.as_ref();
9008    let mut output = String::with_capacity(digest.len() * 2);
9009    for byte in digest {
9010        output.push_str(&format!("{byte:02X}"));
9011    }
9012    output
9013}
9014
9015fn pins_db_path(app_root: &Path) -> PathBuf {
9016    if uses_packaged_layout(app_root) {
9017        app_root.join("pinning.db")
9018    } else {
9019        app_root.join("pins.db")
9020    }
9021}
9022
9023fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
9024    let mut has_current_column = false;
9025    let mut has_legacy_column = false;
9026
9027    for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
9028        match column_name.as_str() {
9029            "type" => has_current_column = true,
9030            "pin_type" => has_legacy_column = true,
9031            _ => {}
9032        }
9033    }
9034
9035    Ok(if has_current_column {
9036        Some("type")
9037    } else if has_legacy_column {
9038        Some("pin_type")
9039    } else {
9040        None
9041    })
9042}
9043
9044fn decode_pin_type(pin_type_int: i64) -> PinType {
9045    match pin_type_int {
9046        4 => PinType::Blocking,
9047        3 => PinType::Gating,
9048        _ => PinType::Pinning,
9049    }
9050}
9051
9052#[cfg(windows)]
9053fn dispatch_installer(
9054    installer_path: &Path,
9055    installer_type: &str,
9056    request: &InstallRequest,
9057    manifest: &Manifest,
9058    installer: &Installer,
9059) -> Result<i32> {
9060    match installer_type {
9061        "msi" | "wix" if should_run_msi_direct(request, installer) => run_msi_direct(
9062            installer_path,
9063            &installer_direct_msi_arguments(installer_type, request, manifest, installer_path, installer),
9064        ),
9065        "msi" | "wix" => shell_execute_installer(
9066            Path::new("msiexec.exe"),
9067            &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9068            should_elevate_msi_shell_execute(installer),
9069        ),
9070        "msix" | "appx" => {
9071            use std::process::Command;
9072
9073            let mut cmd = Command::new("powershell");
9074            cmd.arg("-NoProfile")
9075                .arg("-Command")
9076                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
9077            let status = cmd.status().context("failed to run Add-AppxPackage")?;
9078            Ok(status.code().unwrap_or(-1))
9079        }
9080        "zip" => {
9081            // A `zip` at the top-level installer position is treated as portable when
9082            // `NestedInstallerType=portable` is specified — that's the shape every
9083            // portable in the winget catalog uses (e.g. BurntSushi.ripgrep.MSVC).
9084            // Anything else still falls through to the historical extract-to-Programs
9085            // behavior, which isn't really correct but at least doesn't regress.
9086            if installer
9087                .nested_installer_type
9088                .as_deref()
9089                .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9090            {
9091                return install_portable(installer_path, request, manifest, installer);
9092            }
9093            let target = dirs::data_local_dir()
9094                .unwrap_or_else(|| PathBuf::from("."))
9095                .join("Programs");
9096            fs::create_dir_all(&target)?;
9097            let file = fs::File::open(installer_path)?;
9098            let mut archive = ZipArchive::new(file)?;
9099            archive.extract(&target)?;
9100            Ok(0)
9101        }
9102        "portable" => install_portable(installer_path, request, manifest, installer),
9103        // exe, inno, nullsoft, burn, etc. are launched through ShellExecute so
9104        // Windows can honor installer manifests and legacy elevation detection.
9105        _ => shell_execute_installer(
9106            installer_path,
9107            &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9108            false,
9109        ),
9110    }
9111}
9112
9113#[cfg(windows)]
9114fn should_run_msi_direct(request: &InstallRequest, installer: &Installer) -> bool {
9115    request.mode == InstallerMode::Silent && !should_elevate_msi_shell_execute(installer)
9116}
9117
9118#[cfg(windows)]
9119fn should_elevate_msi_shell_execute(installer: &Installer) -> bool {
9120    installer
9121        .scope
9122        .as_deref()
9123        .is_some_and(|scope| scope.eq_ignore_ascii_case("machine"))
9124}
9125
9126#[cfg(windows)]
9127fn shell_execute_installer(installer_path: &Path, args: &[String], run_as: bool) -> Result<i32> {
9128    use std::ffi::OsStr;
9129
9130    use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, WAIT_OBJECT_0};
9131    use windows_sys::Win32::System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject};
9132    use windows_sys::Win32::UI::Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW};
9133    use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
9134
9135    let file = wide_null(installer_path.as_os_str());
9136    let parameters = wide_null(OsStr::new(&join_windows_arguments(args)));
9137    let verb = run_as.then(|| wide_null(OsStr::new("runas")));
9138
9139    // SAFETY: SHELLEXECUTEINFOW is a plain Win32 data structure; zero initialization
9140    // is the documented starting point before setting cbSize and the fields we use.
9141    let mut exec_info = unsafe { std::mem::zeroed::<SHELLEXECUTEINFOW>() };
9142    exec_info.cbSize =
9143        u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).expect("SHELLEXECUTEINFOW size fits in u32");
9144    exec_info.fMask = SEE_MASK_NOCLOSEPROCESS;
9145    exec_info.lpFile = file.as_ptr();
9146    exec_info.lpParameters = parameters.as_ptr();
9147    if let Some(verb) = verb.as_ref() {
9148        exec_info.lpVerb = verb.as_ptr();
9149    }
9150    exec_info.nShow = SW_SHOW;
9151
9152    // SAFETY: exec_info points to a valid SHELLEXECUTEINFOW whose string pointers
9153    // reference live, nul-terminated UTF-16 buffers for the duration of the call.
9154    if unsafe { ShellExecuteExW(&mut exec_info) } == 0 {
9155        bail!(
9156            "failed to launch installer through ShellExecute (Win32 error {})",
9157            // SAFETY: GetLastError reads the calling thread's last-error value.
9158            unsafe { GetLastError() }
9159        );
9160    }
9161
9162    if exec_info.hProcess.is_null() {
9163        bail!("ShellExecute did not return an installer process handle");
9164    }
9165
9166    // SAFETY: ShellExecuteExW returned a non-null process handle with
9167    // SEE_MASK_NOCLOSEPROCESS, so waiting on it is valid.
9168    let wait_result = unsafe { WaitForSingleObject(exec_info.hProcess, INFINITE) };
9169    if wait_result != WAIT_OBJECT_0 {
9170        // SAFETY: hProcess is the owned process handle returned by ShellExecuteExW.
9171        let _ = unsafe { CloseHandle(exec_info.hProcess) };
9172        bail!("failed while waiting for installer process (Win32 wait result {wait_result})");
9173    }
9174
9175    let mut exit_code = 0u32;
9176    // SAFETY: hProcess is still open and valid, and exit_code points to writable memory.
9177    let got_exit_code = unsafe { GetExitCodeProcess(exec_info.hProcess, &mut exit_code) };
9178    // SAFETY: hProcess is the owned process handle returned by ShellExecuteExW.
9179    let _ = unsafe { CloseHandle(exec_info.hProcess) };
9180    if got_exit_code == 0 {
9181        // SAFETY: GetLastError reads the calling thread's last-error value.
9182        let error = unsafe { GetLastError() };
9183        bail!("failed to retrieve installer exit code (Win32 error {error})");
9184    }
9185
9186    Ok(exit_code.cast_signed())
9187}
9188
9189#[cfg(windows)]
9190fn wide_null(value: &std::ffi::OsStr) -> Vec<u16> {
9191    value.encode_wide().chain(std::iter::once(0)).collect()
9192}
9193
9194#[cfg(windows)]
9195fn run_msi_direct(installer_path: &Path, args: &[String]) -> Result<i32> {
9196    use std::ffi::OsStr;
9197    use std::ptr::{null, null_mut};
9198
9199    use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
9200        INSTALLUILEVEL_NONE, INSTALLUILEVEL_UACONLY, MsiEnableLogW, MsiInstallProductW, MsiSetInternalUI,
9201    };
9202
9203    let parsed = parse_msi_direct_arguments(args)?;
9204    let package_path = wide_null(installer_path.as_os_str());
9205    let properties = wide_null(OsStr::new(&parsed.properties));
9206    let property_ptr = if parsed.properties.is_empty() {
9207        null()
9208    } else {
9209        properties.as_ptr()
9210    };
9211    let log_path = parsed.log_path.as_ref().map(|path| wide_null(OsStr::new(path)));
9212
9213    if let Some(path) = log_path.as_ref() {
9214        // SAFETY: path is a live, nul-terminated UTF-16 buffer; mode and attributes
9215        // are parsed from MSI logging switches.
9216        let result = unsafe { MsiEnableLogW(parsed.log_mode, path.as_ptr(), parsed.log_attributes) };
9217        if result != 0 {
9218            bail!("failed to enable MSI logging (Win32 error {result})");
9219        }
9220    } else {
9221        // SAFETY: Passing a null file with mode 0 disables MSI logging.
9222        let _ = unsafe { MsiEnableLogW(0, null(), 0) };
9223    }
9224
9225    let ui_level = if parsed.ui_level == INSTALLUILEVEL_NONE as u32 {
9226        (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY) as u32
9227    } else {
9228        parsed.ui_level
9229    };
9230    // SAFETY: ui_level is composed from documented INSTALLUILEVEL flags; null HWND
9231    // matches the MsiSetInternalUI contract when no owner window is supplied.
9232    unsafe { MsiSetInternalUI(ui_level.cast_signed(), null_mut()) };
9233    // SAFETY: package_path and property_ptr are valid, nul-terminated UTF-16
9234    // strings (or null for no properties) for the duration of the call.
9235    let result = unsafe { MsiInstallProductW(package_path.as_ptr(), property_ptr) };
9236    Ok(result.cast_signed())
9237}
9238
9239#[cfg(any(windows, test))]
9240#[derive(Debug, PartialEq, Eq)]
9241struct MsiDirectArguments {
9242    ui_level: u32,
9243    properties: String,
9244    log_mode: u32,
9245    log_path: Option<String>,
9246    log_attributes: u32,
9247}
9248
9249#[cfg(any(windows, test))]
9250fn parse_msi_direct_arguments(args: &[String]) -> Result<MsiDirectArguments> {
9251    const INSTALLUILEVEL_DEFAULT: u32 = 1;
9252    const INSTALLUILEVEL_NONE: u32 = 2;
9253    const INSTALLUILEVEL_BASIC: u32 = 3;
9254    const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9255    const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9256    const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9257    const LOGMODE_DEFAULT: u32 = 1 | 2 | 4 | 16 | 128 | 256 | 512;
9258    const LOGMODE_ALL: u32 = LOGMODE_DEFAULT | 8 | 1024 | 2048;
9259    const LOGMODE_VERBOSE: u32 = 4096;
9260    const LOGMODE_EXTRADEBUG: u32 = 8192;
9261    const LOGATTR_APPEND: u32 = 1;
9262    const LOGATTR_FLUSH: u32 = 2;
9263
9264    let mut parsed = MsiDirectArguments {
9265        ui_level: INSTALLUILEVEL_DEFAULT,
9266        properties: String::new(),
9267        log_mode: 0,
9268        log_path: None,
9269        log_attributes: 0,
9270    };
9271
9272    let mut index = 0;
9273    while index < args.len() {
9274        let token = &args[index];
9275        if is_msi_switch(token) {
9276            let option = &token[1..];
9277            if option.eq_ignore_ascii_case("quiet") {
9278                parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9279            } else if option.eq_ignore_ascii_case("passive") {
9280                parsed.ui_level = INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL;
9281                append_msi_property(&mut parsed.properties, "REBOOTPROMPT=S");
9282            } else if option.eq_ignore_ascii_case("norestart") {
9283                append_msi_property(&mut parsed.properties, "REBOOT=ReallySuppress");
9284            } else if option.eq_ignore_ascii_case("forcerestart") {
9285                append_msi_property(&mut parsed.properties, "REBOOT=Force");
9286            } else if option.eq_ignore_ascii_case("promptrestart") {
9287                append_msi_property(&mut parsed.properties, "REBOOTPROMPT=\"\"");
9288            } else if option.eq_ignore_ascii_case("log") {
9289                index += 1;
9290                let path = args.get(index).ok_or_else(|| anyhow!("MSI /log requires a log path"))?;
9291                parsed.log_mode = LOGMODE_ALL;
9292                parsed.log_path = Some(path.clone());
9293            } else if option.len() == 1 && option.eq_ignore_ascii_case("q") {
9294                parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9295            } else if option.starts_with(['q', 'Q']) {
9296                parsed.ui_level = parse_msi_ui_level(&option[1..])?;
9297            } else if option.starts_with(['l', 'L']) {
9298                index += 1;
9299                let path = args
9300                    .get(index)
9301                    .ok_or_else(|| anyhow!("MSI logging switch requires a log path"))?;
9302                let (mode, attributes) = parse_msi_log_mode(
9303                    &option[1..],
9304                    LOGMODE_DEFAULT,
9305                    LOGMODE_ALL,
9306                    LOGMODE_VERBOSE,
9307                    LOGMODE_EXTRADEBUG,
9308                    LOGATTR_APPEND,
9309                    LOGATTR_FLUSH,
9310                )?;
9311                parsed.log_mode = mode;
9312                parsed.log_attributes = attributes;
9313                parsed.log_path = Some(path.clone());
9314            } else {
9315                bail!("unsupported MSI switch for direct MSI execution: {token}");
9316            }
9317        } else {
9318            append_msi_property(&mut parsed.properties, &format_msi_property(token)?);
9319        }
9320        index += 1;
9321    }
9322
9323    Ok(parsed)
9324}
9325
9326#[cfg(any(windows, test))]
9327fn is_msi_switch(token: &str) -> bool {
9328    token.starts_with('/') || token.starts_with('-')
9329}
9330
9331#[cfg(any(windows, test))]
9332fn append_msi_property(properties: &mut String, property: &str) {
9333    if !properties.is_empty() {
9334        properties.push(' ');
9335    }
9336    properties.push_str(property);
9337}
9338
9339#[cfg(any(windows, test))]
9340fn format_msi_property(token: &str) -> Result<String> {
9341    let Some((name, value)) = token.split_once('=') else {
9342        bail!("invalid MSI property argument: {token}");
9343    };
9344    if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
9345        bail!("invalid MSI property name: {name}");
9346    }
9347    if value.contains(char::is_whitespace) && !(value.starts_with('"') && value.ends_with('"')) {
9348        Ok(format!("{name}=\"{}\"", value.replace('"', "\"\"")))
9349    } else {
9350        Ok(token.to_owned())
9351    }
9352}
9353
9354#[cfg(any(windows, test))]
9355fn parse_msi_ui_level(modifier: &str) -> Result<u32> {
9356    const INSTALLUILEVEL_NONE: u32 = 2;
9357    const INSTALLUILEVEL_BASIC: u32 = 3;
9358    const INSTALLUILEVEL_REDUCED: u32 = 4;
9359    const INSTALLUILEVEL_FULL: u32 = 5;
9360    const INSTALLUILEVEL_ENDDIALOG: u32 = 0x80;
9361    const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9362    const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9363    const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9364
9365    let modifier = if modifier.is_empty() { "n" } else { modifier };
9366    let mut chars = modifier.chars();
9367    let Some(first) = chars.next() else {
9368        return Ok(INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY);
9369    };
9370    let mut ui_level = match first.to_ascii_lowercase() {
9371        'f' => INSTALLUILEVEL_FULL,
9372        'r' => INSTALLUILEVEL_REDUCED,
9373        'b' => INSTALLUILEVEL_BASIC,
9374        'n' => INSTALLUILEVEL_NONE,
9375        '+' => INSTALLUILEVEL_NONE | INSTALLUILEVEL_ENDDIALOG,
9376        _ => bail!("invalid MSI /q modifier: {modifier}"),
9377    };
9378
9379    for ch in chars {
9380        match ch {
9381            '+' => ui_level |= INSTALLUILEVEL_ENDDIALOG,
9382            '-' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_PROGRESSONLY,
9383            '!' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_HIDECANCEL,
9384            '-' | '!' => bail!("MSI /q modifier {ch} is only valid with basic UI"),
9385            _ => bail!("invalid MSI /q modifier: {modifier}"),
9386        }
9387    }
9388
9389    if ui_level & 0xF == INSTALLUILEVEL_NONE {
9390        ui_level |= INSTALLUILEVEL_UACONLY;
9391    }
9392    Ok(ui_level)
9393}
9394
9395#[cfg(any(windows, test))]
9396#[allow(clippy::too_many_arguments)]
9397fn parse_msi_log_mode(
9398    modifier: &str,
9399    default_mode: u32,
9400    all_mode: u32,
9401    verbose: u32,
9402    extra_debug: u32,
9403    append: u32,
9404    flush: u32,
9405) -> Result<(u32, u32)> {
9406    let mut mode = 0;
9407    let mut attributes = 0;
9408    for ch in modifier.chars() {
9409        match ch.to_ascii_lowercase() {
9410            '*' => mode |= all_mode,
9411            'm' => mode |= 1,
9412            'e' => mode |= 2,
9413            'w' => mode |= 4,
9414            'u' => mode |= 8,
9415            'i' => mode |= 16,
9416            'o' => mode |= 128,
9417            'a' => mode |= 256,
9418            'r' => mode |= 512,
9419            'p' => mode |= 1024,
9420            'c' => mode |= 2048,
9421            'v' => mode |= verbose,
9422            'x' => mode |= extra_debug,
9423            '+' => attributes |= append,
9424            '!' => attributes |= flush,
9425            _ => bail!("invalid MSI /l modifier: {modifier}"),
9426        }
9427    }
9428    if mode == 0 {
9429        mode = default_mode;
9430    }
9431    Ok((mode, attributes))
9432}
9433
9434#[cfg(not(windows))]
9435fn dispatch_installer(
9436    _installer_path: &Path,
9437    _installer_type: &str,
9438    _request: &InstallRequest,
9439    _manifest: &Manifest,
9440    _installer: &Installer,
9441) -> Result<i32> {
9442    bail!("Installing packages is only supported on Windows")
9443}
9444
9445#[cfg(test)]
9446fn installer_command_arguments(
9447    installer_type: &str,
9448    request: &InstallRequest,
9449    _manifest: &Manifest,
9450    installer_path: &Path,
9451    installer: &Installer,
9452) -> Vec<String> {
9453    installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9454}
9455
9456#[cfg(any(windows, test))]
9457fn installer_direct_msi_arguments(
9458    installer_type: &str,
9459    request: &InstallRequest,
9460    _manifest: &Manifest,
9461    installer_path: &Path,
9462    installer: &Installer,
9463) -> Vec<String> {
9464    installer_command_arguments_with_options(installer_type, request, installer_path, installer, false)
9465}
9466
9467#[cfg(any(windows, test))]
9468fn installer_shell_execute_arguments(
9469    installer_type: &str,
9470    request: &InstallRequest,
9471    _manifest: &Manifest,
9472    installer_path: &Path,
9473    installer: &Installer,
9474) -> Vec<String> {
9475    installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9476}
9477
9478#[cfg(any(windows, test))]
9479fn installer_command_arguments_with_options(
9480    installer_type: &str,
9481    request: &InstallRequest,
9482    installer_path: &Path,
9483    installer: &Installer,
9484    include_msi_install_target: bool,
9485) -> Vec<String> {
9486    if let Some(override_args) = request.override_args.as_deref() {
9487        return split_installer_switches(override_args);
9488    }
9489
9490    let mut args = Vec::new();
9491    match installer_type {
9492        "msi" | "wix" if include_msi_install_target => {
9493            args.push("/i".to_owned());
9494            args.push(installer_path.display().to_string());
9495        }
9496        "msix" | "appx" | "zip" => return Vec::new(),
9497        _ => {}
9498    }
9499
9500    let configured = match request.mode {
9501        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
9502        InstallerMode::SilentWithProgress => installer
9503            .switches
9504            .silent_with_progress
9505            .as_deref()
9506            .or(installer.switches.silent.as_deref()),
9507        InstallerMode::Silent => installer
9508            .switches
9509            .silent
9510            .as_deref()
9511            .or(installer.switches.silent_with_progress.as_deref()),
9512    };
9513
9514    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
9515        args.extend(split_installer_switches(value));
9516    } else {
9517        args.extend(default_installer_arguments(installer_type, request.mode));
9518    }
9519
9520    append_switches(
9521        &mut args,
9522        resolve_template_switch(
9523            installer.switches.log.as_deref(),
9524            default_log_switch(installer_type),
9525            request.log_path.as_ref().map(|path| path.display().to_string()),
9526            "<LOGPATH>",
9527        ),
9528    );
9529    append_switches(&mut args, installer.switches.custom.clone());
9530    append_switches(&mut args, request.custom.clone());
9531    append_switches(
9532        &mut args,
9533        resolve_template_switch(
9534            installer.switches.install_location.as_deref(),
9535            default_install_location_switch(installer_type),
9536            request.install_location.clone(),
9537            "<INSTALLPATH>",
9538        ),
9539    );
9540    args
9541}
9542
9543#[cfg(any(windows, test))]
9544fn join_windows_arguments(args: &[String]) -> String {
9545    args.iter()
9546        .map(|arg| quote_windows_argument(arg))
9547        .collect::<Vec<_>>()
9548        .join(" ")
9549}
9550
9551#[cfg(any(windows, test))]
9552fn quote_windows_argument(arg: &str) -> String {
9553    if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') {
9554        return arg.to_owned();
9555    }
9556
9557    let mut quoted = String::from("\"");
9558    let mut backslashes = 0usize;
9559    for ch in arg.chars() {
9560        match ch {
9561            '\\' => backslashes += 1,
9562            '"' => {
9563                quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
9564                quoted.push('"');
9565                backslashes = 0;
9566            }
9567            _ => {
9568                quoted.push_str(&"\\".repeat(backslashes));
9569                backslashes = 0;
9570                quoted.push(ch);
9571            }
9572        }
9573    }
9574    quoted.push_str(&"\\".repeat(backslashes * 2));
9575    quoted.push('"');
9576    quoted
9577}
9578
9579#[cfg(any(windows, test))]
9580fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
9581    match mode {
9582        InstallerMode::Interactive => Vec::new(),
9583        InstallerMode::SilentWithProgress => match installer_type {
9584            "inno" => vec![
9585                "/SP-".to_owned(),
9586                "/SILENT".to_owned(),
9587                "/SUPPRESSMSGBOXES".to_owned(),
9588                "/NORESTART".to_owned(),
9589            ],
9590            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
9591            "nullsoft" | "nsis" => vec!["/S".to_owned()],
9592            _ => vec!["/SILENT".to_owned()],
9593        },
9594        InstallerMode::Silent => match installer_type {
9595            "inno" => vec![
9596                "/SP-".to_owned(),
9597                "/VERYSILENT".to_owned(),
9598                "/SUPPRESSMSGBOXES".to_owned(),
9599                "/NORESTART".to_owned(),
9600            ],
9601            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
9602            "nullsoft" | "nsis" => vec!["/S".to_owned()],
9603            _ => vec!["/S".to_owned()],
9604        },
9605    }
9606}
9607
9608#[cfg(any(windows, test))]
9609fn default_log_switch(installer_type: &str) -> Option<&'static str> {
9610    match installer_type {
9611        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
9612        "inno" => Some("/LOG=\"<LOGPATH>\""),
9613        _ => None,
9614    }
9615}
9616
9617#[cfg(any(windows, test))]
9618fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
9619    match installer_type {
9620        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
9621        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
9622        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
9623        _ => None,
9624    }
9625}
9626
9627#[cfg(any(windows, test))]
9628fn resolve_template_switch(
9629    manifest_value: Option<&str>,
9630    fallback: Option<&str>,
9631    replacement: Option<String>,
9632    token: &str,
9633) -> Option<String> {
9634    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
9635    let replacement = replacement?;
9636    Some(template.replace(token, &replacement))
9637}
9638
9639#[cfg(any(windows, test))]
9640fn append_switches(args: &mut Vec<String>, value: Option<String>) {
9641    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
9642        args.extend(split_installer_switches(&value));
9643    }
9644}
9645
9646#[cfg(any(windows, test))]
9647fn split_installer_switches(value: &str) -> Vec<String> {
9648    let mut args = Vec::new();
9649    let mut current = String::new();
9650    let mut in_quotes = false;
9651
9652    for ch in value.chars() {
9653        match ch {
9654            '"' => in_quotes = !in_quotes,
9655            c if c.is_whitespace() && !in_quotes => {
9656                if !current.is_empty() {
9657                    args.push(std::mem::take(&mut current));
9658                }
9659            }
9660            _ => current.push(ch),
9661        }
9662    }
9663
9664    if !current.is_empty() {
9665        args.push(current);
9666    }
9667
9668    args
9669}
9670
9671#[cfg(windows)]
9672fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
9673    if request.purge && request.preserve {
9674        bail!("--purge and --preserve cannot be used together.");
9675    }
9676
9677    if installed
9678        .installer_category
9679        .as_deref()
9680        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9681    {
9682        return uninstall_portable(installed, request);
9683    }
9684
9685    if (request.purge || request.preserve) && !request.force {
9686        bail!("--purge and --preserve are currently only supported for portable packages.");
9687    }
9688
9689    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
9690        return Ok(exit_code);
9691    }
9692
9693    if let Some(exit_code) = try_uninstall_msix(installed)? {
9694        return Ok(exit_code);
9695    }
9696
9697    bail!(
9698        "No uninstall command found for installed package '{}' ({})",
9699        installed.name,
9700        installed.local_id
9701    );
9702}
9703
9704#[cfg(windows)]
9705fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9706    use std::os::windows::process::CommandExt;
9707    use std::process::Command;
9708
9709    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
9710    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9711
9712    let arp_paths = [
9713        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
9714        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
9715    ];
9716
9717    for root in [&hklm, &hkcu] {
9718        for arp_path in &arp_paths {
9719            if let Ok(key) = root.open_subkey(arp_path) {
9720                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
9721                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
9722                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
9723                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
9724                        if !registry_entry_matches_installed_package(
9725                            &subkey_name,
9726                            &display_name,
9727                            product_code.as_deref(),
9728                            installed,
9729                        ) {
9730                            continue;
9731                        }
9732
9733                        if let Some(exit_code) =
9734                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
9735                        {
9736                            return Ok(Some(exit_code));
9737                        }
9738
9739                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
9740                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
9741                            subkey.get_value("UninstallString").ok()
9742                        } else {
9743                            quiet_uninstall_cmd
9744                                .clone()
9745                                .or_else(|| subkey.get_value("UninstallString").ok())
9746                        }
9747                        .context("No uninstall command found in registry")?;
9748
9749                        if is_winget_uninstall_command(&uninstall_cmd)
9750                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
9751                        {
9752                            return Ok(Some(exit_code));
9753                        }
9754
9755                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
9756                        let uninstall_line = build_uninstall_command_with_mode(
9757                            &uninstall_cmd,
9758                            request.mode,
9759                            quiet_uninstall_cmd.is_some(),
9760                            log_path.as_deref(),
9761                        );
9762                        // `UninstallString` is a complete command line and may already contain
9763                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
9764                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
9765                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
9766                        // rejects with "is not recognized as an internal or external command".
9767                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
9768                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
9769                        // automatic quoting so we can feed cmd the literal command line.
9770                        let mut cmd = Command::new("cmd");
9771                        cmd.raw_arg("/S /C");
9772                        cmd.raw_arg(format!("\"{uninstall_line}\""));
9773                        let status = cmd.status().context("failed to run uninstaller")?;
9774                        return Ok(Some(status.code().unwrap_or(-1)));
9775                    }
9776                }
9777            }
9778        }
9779    }
9780
9781    Ok(None)
9782}
9783
9784#[cfg(windows)]
9785fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
9786    use std::process::Command;
9787
9788    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
9789        return Ok(None);
9790    }
9791
9792    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
9793    let mut cmd = Command::new("powershell");
9794    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
9795        package_full_name,
9796        &installed.package_family_names,
9797    ));
9798    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
9799    Ok(Some(status.code().unwrap_or(-1)))
9800}
9801
9802#[cfg(windows)]
9803fn arp_subkey_name(local_id: &str) -> Option<&str> {
9804    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
9805}
9806
9807#[cfg(windows)]
9808fn registry_entry_matches_installed_package(
9809    subkey_name: &str,
9810    display_name: &str,
9811    product_code: Option<&str>,
9812    installed: &ListMatch,
9813) -> bool {
9814    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
9815        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
9816    {
9817        return true;
9818    }
9819
9820    if installed.product_codes.iter().any(|code| {
9821        code.eq_ignore_ascii_case(subkey_name)
9822            || product_code
9823                .map(|value| code.eq_ignore_ascii_case(value))
9824                .unwrap_or(false)
9825    }) {
9826        return true;
9827    }
9828
9829    display_name.eq_ignore_ascii_case(&installed.name)
9830}
9831
9832#[cfg(all(windows, test))]
9833fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
9834    build_uninstall_command_with_mode(
9835        uninstall_cmd,
9836        if silent {
9837            InstallerMode::Silent
9838        } else {
9839            InstallerMode::Interactive
9840        },
9841        has_quiet_command,
9842        None,
9843    )
9844}
9845
9846#[cfg(windows)]
9847fn build_uninstall_command_with_mode(
9848    uninstall_cmd: &str,
9849    mode: InstallerMode,
9850    has_quiet_command: bool,
9851    log_path: Option<&str>,
9852) -> String {
9853    let uninstall_cmd = log_path
9854        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
9855        .unwrap_or_else(|| uninstall_cmd.to_owned());
9856    if mode == InstallerMode::Interactive || has_quiet_command {
9857        return uninstall_cmd;
9858    }
9859
9860    let lower = uninstall_cmd.to_ascii_lowercase();
9861    if is_winget_uninstall_command_lower(&lower) {
9862        return uninstall_cmd;
9863    }
9864    if lower.contains("/quiet")
9865        || lower.contains("/passive")
9866        || lower.contains("/verysilent")
9867        || lower.contains("/silent")
9868        || lower.contains(" /s")
9869    {
9870        return uninstall_cmd;
9871    }
9872
9873    format!("{uninstall_cmd} /S")
9874}
9875
9876#[cfg(windows)]
9877fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9878    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
9879        return Ok(None);
9880    };
9881
9882    let exit_code = run_winget_uninstall(&args)?;
9883    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
9884        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
9885            return Ok(Some(exit_code));
9886        };
9887
9888        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
9889    }
9890
9891    Ok(Some(exit_code))
9892}
9893
9894#[cfg(windows)]
9895fn run_winget_uninstall(args: &[String]) -> Result<i32> {
9896    use std::process::Command;
9897
9898    let status = Command::new("winget")
9899        .args(args)
9900        .status()
9901        .context("failed to run delegated winget uninstall")?;
9902    Ok(status.code().unwrap_or(-1))
9903}
9904
9905#[cfg(windows)]
9906fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
9907    build_winget_uninstall_arguments_with_scope(installed, request, true)
9908}
9909
9910#[cfg(windows)]
9911fn build_winget_uninstall_arguments_with_scope(
9912    installed: &ListMatch,
9913    request: &UninstallRequest,
9914    include_scope: bool,
9915) -> Option<Vec<String>> {
9916    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
9917        return None;
9918    }
9919
9920    let mut args = vec![
9921        "uninstall".to_owned(),
9922        "--id".to_owned(),
9923        installed.id.clone(),
9924        "--exact".to_owned(),
9925        "--disable-interactivity".to_owned(),
9926    ];
9927
9928    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
9929        args.push("--source".to_owned());
9930        args.push(source_name.to_owned());
9931    }
9932
9933    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
9934        args.push("--scope".to_owned());
9935        args.push(scope.to_owned());
9936    }
9937
9938    if request.mode == InstallerMode::Silent {
9939        args.push("--silent".to_owned());
9940    }
9941
9942    Some(args)
9943}
9944
9945#[cfg(windows)]
9946fn is_winget_uninstall_command(command: &str) -> bool {
9947    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
9948}
9949
9950#[cfg(windows)]
9951fn is_winget_uninstall_command_lower(command: &str) -> bool {
9952    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
9953}
9954
9955#[cfg(windows)]
9956fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
9957    let full_name_literal = package_full_name
9958        .map(|value| format!("'{}'", value.replace('\'', "''")))
9959        .unwrap_or_else(|| "$null".to_owned());
9960    let family_names_literal = if package_family_names.is_empty() {
9961        "@()".to_owned()
9962    } else {
9963        format!(
9964            "@({})",
9965            package_family_names
9966                .iter()
9967                .map(|value| format!("'{}'", value.replace('\'', "''")))
9968                .collect::<Vec<_>>()
9969                .join(", ")
9970        )
9971    };
9972
9973    format!(
9974        "$fullName = {full_name_literal}; \
9975         $familyNames = {family_names_literal}; \
9976         $targets = Get-AppxPackage | Where-Object {{ \
9977         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
9978         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
9979         if (-not $targets) {{ exit 1 }}; \
9980         $targets | Remove-AppxPackage"
9981    )
9982}
9983
9984#[cfg(windows)]
9985fn try_run_msi_uninstall(
9986    installed: &ListMatch,
9987    subkey_name: &str,
9988    product_code: Option<&str>,
9989    request: &UninstallRequest,
9990) -> Result<Option<i32>> {
9991    use std::process::Command;
9992
9993    let uninstall_code = installed
9994        .product_codes
9995        .iter()
9996        .map(String::as_str)
9997        .chain(product_code)
9998        .chain(Some(subkey_name))
9999        .find(|value| is_product_code_like(value));
10000
10001    let Some(uninstall_code) = uninstall_code else {
10002        return Ok(None);
10003    };
10004
10005    let mut cmd = Command::new("msiexec");
10006    cmd.arg("/x").arg(uninstall_code);
10007    match request.mode {
10008        InstallerMode::Interactive => {}
10009        InstallerMode::SilentWithProgress => {
10010            cmd.arg("/passive").arg("/norestart");
10011        }
10012        InstallerMode::Silent => {
10013            cmd.arg("/quiet").arg("/norestart");
10014        }
10015    }
10016    if let Some(log_path) = &request.log_path {
10017        cmd.arg("/log").arg(log_path);
10018    }
10019
10020    let status = cmd.status().context("failed to run msiexec uninstall")?;
10021    Ok(Some(status.code().unwrap_or(-1)))
10022}
10023
10024#[cfg(windows)]
10025fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
10026    // Read PortableSymlinkFullPath from the ARP entry *before* we touch the
10027    // registry, so we can remove winget-created shims in Links\ if any. Without
10028    // this, a portable that winget originally installed (with a
10029    // PortableCommandAlias set in its manifest) would leave a dangling symlink
10030    // in %LOCALAPPDATA%\Microsoft\WinGet\Links\ once pinget uninstalls it.
10031    let known_symlink = read_portable_symlink_full_path(installed);
10032
10033    let Some(location) = installed.install_location.as_deref() else {
10034        if request.force {
10035            try_remove_portable_symlinks(installed, &known_symlink, None);
10036            try_remove_portable_registry_entry(installed);
10037            return Ok(0);
10038        }
10039        bail!(
10040            "Portable package '{}' does not expose an install location.",
10041            installed.name
10042        );
10043    };
10044
10045    if request.preserve {
10046        return Ok(0);
10047    }
10048
10049    let path = Path::new(location);
10050    let removed = if path.is_dir() {
10051        fs::remove_dir_all(path)?;
10052        true
10053    } else if path.is_file() {
10054        fs::remove_file(path)?;
10055        true
10056    } else {
10057        false
10058    };
10059
10060    if removed || request.force {
10061        // Once the files are gone (or the user forced past missing files), drop
10062        // the symlink shim and ARP entry too so the package fully disappears.
10063        try_remove_portable_symlinks(installed, &known_symlink, Some(path));
10064        try_remove_portable_registry_entry(installed);
10065        return Ok(0);
10066    }
10067
10068    bail!("Portable package location not found: {location}")
10069}
10070
10071/// Reads `PortableSymlinkFullPath` from the ARP subkey backing `installed`
10072/// when present. This value is recorded for portable installs that created a
10073/// `Links\<alias>.exe` symlink, including pinget-installed entries when
10074/// `install_portable` created that link. Entries without a created/recorded
10075/// portable symlink return `None`.
10076#[cfg(windows)]
10077fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
10078    const ARP_PREFIX: &str = r"ARP\";
10079    if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
10080        let mut parts = rest.splitn(3, '\\');
10081        let scope = parts.next().unwrap_or("");
10082        let _arch = parts.next();
10083        let subkey_name = parts.next().unwrap_or("");
10084        if subkey_name.is_empty() {
10085            return None;
10086        }
10087        let hive = if scope.eq_ignore_ascii_case("User") {
10088            RegKey::predef(HKEY_CURRENT_USER)
10089        } else {
10090            RegKey::predef(HKEY_LOCAL_MACHINE)
10091        };
10092        let subkey = hive
10093            .open_subkey(format!(
10094                r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
10095            ))
10096            .ok()?;
10097        return read_reg_string(&subkey, "PortableSymlinkFullPath");
10098    }
10099    None
10100}
10101
10102/// Removes the symlink shims winget would have created for this portable.
10103/// Tries (1) the exact path from `PortableSymlinkFullPath` if known, and
10104/// (2) any symlink in `%LOCALAPPDATA%\Microsoft\WinGet\Links\` whose target
10105/// resolves under `install_location` (defensive sweep for shims winget made
10106/// but didn't surface in the registry). Best-effort: failures are swallowed.
10107#[cfg(windows)]
10108fn try_remove_portable_symlinks(
10109    _installed: &ListMatch,
10110    known_symlink: &Option<String>,
10111    install_location: Option<&Path>,
10112) {
10113    if let Some(path) = known_symlink.as_deref() {
10114        let _ = fs::remove_file(path);
10115    }
10116
10117    // Defensive sweep: walk Links\ and remove shims whose stored target was
10118    // inside (or equal to) install_location. By this point install_location is
10119    // already deleted, so we compare the symlink's stored target string (which
10120    // the filesystem retains even after the target itself is gone).
10121    let Some(install_dir) = install_location else { return };
10122
10123    let Some(local) = dirs::data_local_dir() else { return };
10124    let links_root = local.join("Microsoft").join("WinGet").join("Links");
10125    let Ok(entries) = fs::read_dir(&links_root) else { return };
10126    for entry in entries.flatten() {
10127        let path = entry.path();
10128        let Ok(target) = fs::read_link(&path) else { continue };
10129        if path_starts_with_case_insensitive(&target, install_dir) {
10130            let _ = fs::remove_file(&path);
10131        }
10132    }
10133}
10134
10135/// Case-insensitive, component-aware path-prefix check for Windows. A raw
10136/// `str::starts_with` on the path strings would let `C:\Foo` falsely match
10137/// `C:\Foobar`; iterating path components avoids that. Used by the Links\
10138/// sweep so an uninstall of one portable can't accidentally delete shims that
10139/// belong to a different portable whose install dir happens to share a
10140/// string-prefix.
10141#[cfg(windows)]
10142fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
10143    let lower_components = |p: &Path| -> Vec<String> {
10144        p.components()
10145            .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
10146            .collect()
10147    };
10148    let parent_components = lower_components(parent);
10149    let child_components = lower_components(child);
10150    if parent_components.is_empty() || parent_components.len() > child_components.len() {
10151        return false;
10152    }
10153    parent_components
10154        .iter()
10155        .zip(child_components.iter())
10156        .all(|(a, b)| a == b)
10157}
10158
10159/// Best-effort removal of the HKCU/HKLM ARP subkey backing a portable entry.
10160/// We match on local_id (`ARP\<scope>\<arch>\<subkey>`) when present so we
10161/// delete the exact key the list view surfaced; otherwise we walk the standard
10162/// Uninstall path and match on WinGetPackageIdentifier. Failures are logged via
10163/// the returned warning chain in callers — we never bubble a registry-cleanup
10164/// failure as a hard error because the file deletion already succeeded.
10165#[cfg(windows)]
10166fn try_remove_portable_registry_entry(installed: &ListMatch) {
10167    // Collect (is_hkcu, subkey_path) pairs so we can re-open hives at delete
10168    // time instead of holding clones of RegKey (which doesn't implement Clone).
10169    let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
10170        // local_id format is `ARP\<scope>\<arch>\<subkey>` — pick the right hive/path.
10171        let mut parts = rest.splitn(3, '\\');
10172        let scope = parts.next().unwrap_or("");
10173        let _arch = parts.next();
10174        let subkey_name = parts.next().unwrap_or("");
10175        if subkey_name.is_empty() {
10176            return;
10177        }
10178        let is_hkcu = scope.eq_ignore_ascii_case("User");
10179        vec![(
10180            is_hkcu,
10181            format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
10182        )]
10183    } else {
10184        // Fall back to scanning by WinGetPackageIdentifier.
10185        let mut found = Vec::new();
10186        for (is_hkcu, hive) in [
10187            (true, RegKey::predef(HKEY_CURRENT_USER)),
10188            (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
10189        ] {
10190            let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
10191                continue;
10192            };
10193            for name in uninstall.enum_keys().flatten() {
10194                let Ok(subkey) = uninstall.open_subkey(&name) else {
10195                    continue;
10196                };
10197                if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
10198                    found.push((
10199                        is_hkcu,
10200                        format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
10201                    ));
10202                }
10203            }
10204        }
10205        found
10206    };
10207
10208    for (is_hkcu, path) in candidates {
10209        let hive = if is_hkcu {
10210            RegKey::predef(HKEY_CURRENT_USER)
10211        } else {
10212            RegKey::predef(HKEY_LOCAL_MACHINE)
10213        };
10214        let _ = hive.delete_subkey_all(&path);
10215    }
10216}
10217
10218#[cfg(windows)]
10219fn is_product_code_like(value: &str) -> bool {
10220    value.starts_with('{') && value.ends_with('}')
10221}
10222
10223#[cfg(not(windows))]
10224fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
10225    bail!("Uninstalling packages is only supported on Windows")
10226}
10227
10228/// Installs (or upgrades) a portable WinGet package.
10229///
10230/// Handles both `InstallerType: portable` (single binary download) and
10231/// `InstallerType: zip` with `NestedInstallerType: portable` (zip containing
10232/// the binary at a relative path), which is the shape every portable in the
10233/// community winget catalog uses today.
10234///
10235/// Behavior matches winget's portable workflow closely enough that the resulting
10236/// HKCU ARP entry is recognized by `winget list` and by pinget's own list view:
10237/// - Resolves the target install directory in this priority order:
10238///   1. `request.install_location` if provided (passed for upgrades)
10239///   2. Existing `InstallLocation` from the registry ARP entry (so upgrades
10240///      with no explicit location still preserve the user's custom path)
10241///   3. WinGet's default user portable root, `%LOCALAPPDATA%\Microsoft\WinGet
10242///      \Packages\<PackageId>_<SourceId>`
10243/// - If an existing pinget-owned install lives in that directory
10244///   (`InstallDirectoryCreated=1`), cleans its contents before extracting the
10245///   new version so leftover files from older versions don't pile up.
10246/// - Extracts the zip (or copies the standalone binary), then writes the ARP
10247///   registry entry that `winget list` and pinget's `collect_uninstall_view`
10248///   both read from.
10249#[cfg(windows)]
10250fn install_portable(
10251    installer_path: &Path,
10252    request: &InstallRequest,
10253    manifest: &Manifest,
10254    installer: &Installer,
10255) -> Result<i32> {
10256    let source_identifier = portable_source_identifier(&request.query);
10257    let existing_entry = read_existing_portable_entry(&manifest.id);
10258    // Reuse the existing subkey name on upgrade so we don't orphan the prior
10259    // entry by writing to a different one.
10260    let subkey_name = existing_entry
10261        .as_ref()
10262        .map(|entry| entry.subkey_name.clone())
10263        .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
10264
10265    let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
10266
10267    // If we created this directory on a previous install, wipe it clean so the
10268    // new version doesn't co-exist with leftovers from the previous one. We do
10269    // not touch a directory we don't own (InstallDirectoryCreated != 1), to
10270    // avoid stomping user data.
10271    let prev_dir_created = existing_entry
10272        .as_ref()
10273        .and_then(|entry| entry.install_directory_created)
10274        == Some(1);
10275    let dir_existed = target_dir.is_dir();
10276    if prev_dir_created && dir_existed {
10277        clean_directory_contents(&target_dir).with_context(|| {
10278            format!(
10279                "failed to clean existing portable install directory: {}",
10280                target_dir.display()
10281            )
10282        })?;
10283    }
10284
10285    fs::create_dir_all(&target_dir)
10286        .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
10287    // The "we created it" flag stays sticky across upgrades — if a previous
10288    // install marked it created, it still counts as created by us even when
10289    // the dir already existed this round.
10290    let install_directory_created = !dir_existed || prev_dir_created;
10291
10292    let installer_type = installer.installer_type.as_deref().unwrap_or("");
10293    let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
10294        let file = fs::File::open(installer_path)
10295            .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
10296        let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
10297        archive
10298            .extract(&target_dir)
10299            .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
10300        // For nested-portable zips the binary lives at the RelativeFilePath the
10301        // manifest declares. We record the first one as PortableTargetFullPath so
10302        // winget's portable uninstall workflow can identify which file to remove.
10303        installer
10304            .nested_installer_files
10305            .first()
10306            .map(|file| target_dir.join(&file.relative_file_path))
10307            .unwrap_or_else(|| target_dir.clone())
10308    } else {
10309        // Standalone portable: copy the downloaded file into target_dir using
10310        // its original filename. winget would optionally rename to the
10311        // PortableCommandAlias; pinget keeps the original name for now.
10312        let basename = installer_path
10313            .file_name()
10314            .ok_or_else(|| anyhow!("portable installer has no filename"))?;
10315        let copied = target_dir.join(basename);
10316        fs::copy(installer_path, &copied)
10317            .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
10318        copied
10319    };
10320
10321    // Create the Links\<alias>.exe shim so users can invoke the portable from
10322    // any shell, matching winget's portable workflow. Failures are non-fatal —
10323    // when the user lacks SeCreateSymbolicLink (no Developer Mode, not admin)
10324    // we still leave the package installed at install_location.
10325    let alias = determine_portable_alias(installer);
10326    let symlink_full_path = match alias.as_deref() {
10327        Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
10328        _ => None,
10329    };
10330
10331    // Ensure %LOCALAPPDATA%\Microsoft\WinGet\Links is on user PATH so the
10332    // symlink we just created is resolvable from new shells. Tracks whether we
10333    // added it this round so uninstall can decide whether to take it back out.
10334    let added_to_path = if symlink_full_path.is_some() {
10335        try_add_links_dir_to_user_path()
10336    } else {
10337        false
10338    };
10339
10340    write_portable_arp_entry(&PortableArpEntry {
10341        subkey_name: &subkey_name,
10342        install_location: &target_dir,
10343        portable_target_full_path: &portable_target_full_path,
10344        portable_symlink_full_path: symlink_full_path.as_deref(),
10345        install_directory_created,
10346        added_to_path,
10347        source_identifier: &source_identifier,
10348        manifest,
10349    })?;
10350
10351    Ok(0)
10352}
10353
10354#[cfg(windows)]
10355struct PortableArpEntry<'a> {
10356    subkey_name: &'a str,
10357    install_location: &'a Path,
10358    portable_target_full_path: &'a Path,
10359    portable_symlink_full_path: Option<&'a Path>,
10360    install_directory_created: bool,
10361    added_to_path: bool,
10362    source_identifier: &'a str,
10363    manifest: &'a Manifest,
10364}
10365
10366/// Picks the portable command alias from the manifest. Mirrors winget's
10367/// resolution: nested-installer files first (used by zip+portable manifests),
10368/// then the top-level `Commands` field (used by standalone portable manifests).
10369/// Returns None when the manifest doesn't declare a command — the package
10370/// still installs, just without a Links\ shim.
10371#[cfg(windows)]
10372fn determine_portable_alias(installer: &Installer) -> Option<String> {
10373    installer
10374        .nested_installer_files
10375        .iter()
10376        .find_map(|file| file.portable_command_alias.clone())
10377        .or_else(|| installer.commands.first().cloned())
10378}
10379
10380#[cfg(windows)]
10381fn winget_links_dir() -> PathBuf {
10382    dirs::data_local_dir()
10383        .unwrap_or_else(|| PathBuf::from("."))
10384        .join("Microsoft")
10385        .join("WinGet")
10386        .join("Links")
10387}
10388
10389/// Creates (or replaces) `Links\<alias>.exe` as a file symlink pointing at the
10390/// portable's binary. Returns the link path on success. Returns None and logs
10391/// a warning if the user can't create symlinks (no Developer Mode, not admin)
10392/// so the install can still complete without the shim.
10393#[cfg(windows)]
10394fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
10395    use std::os::windows::fs::symlink_file;
10396    let links_dir = winget_links_dir();
10397    if fs::create_dir_all(&links_dir).is_err() {
10398        return None;
10399    }
10400    let link_path = links_dir.join(format!("{alias}.exe"));
10401
10402    // Replace any prior link/file at the path so upgrades repoint to the new
10403    // binary instead of leaving a stale symlink behind.
10404    if link_path.symlink_metadata().is_ok() {
10405        let _ = fs::remove_file(&link_path);
10406    }
10407
10408    // Symlink creation fails with ERROR_PRIVILEGE_NOT_HELD (1314) when the
10409    // process lacks SeCreateSymbolicLink (no Developer Mode, not admin). Treat
10410    // that — and any other I/O failure — as non-fatal: the package files are
10411    // already in place, just without a Links\ shim.
10412    symlink_file(target, &link_path).ok().map(|()| link_path)
10413}
10414
10415/// Adds `Links\` to the user's HKCU\Environment\Path if not already present,
10416/// then broadcasts WM_SETTINGCHANGE so explorer-spawned shells pick up the
10417/// new PATH without a logoff. Returns true if we actually appended PATH this
10418/// call — callers should write `InstallDirectoryAddedToPath` accordingly so a
10419/// follow-up uninstall doesn't take PATH back out for other portables.
10420#[cfg(windows)]
10421fn try_add_links_dir_to_user_path() -> bool {
10422    let links_dir = winget_links_dir();
10423    let links_str = links_dir.to_string_lossy().to_string();
10424    let normalized_links = links_str.to_ascii_lowercase();
10425
10426    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10427    let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
10428        return false;
10429    };
10430    let existing: String = env_key.get_value("Path").unwrap_or_default();
10431    let already_present = existing
10432        .split(';')
10433        .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
10434    if already_present {
10435        return false;
10436    }
10437
10438    let new_path = if existing.is_empty() {
10439        links_str
10440    } else if existing.ends_with(';') {
10441        format!("{existing}{links_str}")
10442    } else {
10443        format!("{existing};{links_str}")
10444    };
10445
10446    if env_key.set_value("Path", &new_path).is_err() {
10447        return false;
10448    }
10449
10450    broadcast_environment_change();
10451    true
10452}
10453
10454#[cfg(windows)]
10455fn broadcast_environment_change() {
10456    use std::ffi::OsStr;
10457    use std::os::windows::ffi::OsStrExt;
10458
10459    use windows_sys::Win32::UI::WindowsAndMessaging::{
10460        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
10461    };
10462
10463    let param: Vec<u16> = OsStr::new("Environment")
10464        .encode_wide()
10465        .chain(std::iter::once(0))
10466        .collect();
10467    // SAFETY: SendMessageTimeoutW only reads `param` for the duration of the
10468    // call (it copies the string before returning), and `result` is a stack
10469    // local we provide. No aliasing or lifetime issues.
10470    unsafe {
10471        let mut result: usize = 0;
10472        SendMessageTimeoutW(
10473            HWND_BROADCAST,
10474            WM_SETTINGCHANGE,
10475            0usize,
10476            param.as_ptr() as isize,
10477            SMTO_ABORTIFHUNG,
10478            5000,
10479            &mut result,
10480        );
10481    }
10482}
10483
10484/// Source identifier embedded in the ARP subkey name. We mirror winget for
10485/// the community repo so winget's UninstallString can resolve the subkey and
10486/// `winget list` still finds the package; everything else uses the source
10487/// name verbatim.
10488#[cfg(windows)]
10489fn portable_source_identifier(query: &PackageQuery) -> String {
10490    match query.source.as_deref() {
10491        Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10492        Some(name) if !name.is_empty() => name.to_owned(),
10493        _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10494    }
10495}
10496
10497#[cfg(windows)]
10498fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
10499    format!("{package_id}_{source_identifier}")
10500}
10501
10502#[cfg(windows)]
10503struct ExistingPortableEntry {
10504    install_location: Option<PathBuf>,
10505    install_directory_created: Option<u32>,
10506    subkey_name: String,
10507}
10508
10509#[cfg(windows)]
10510fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
10511    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10512    let uninstall = hkcu
10513        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
10514        .ok()?;
10515    for name in uninstall.enum_keys().flatten() {
10516        let Ok(subkey) = uninstall.open_subkey(&name) else {
10517            continue;
10518        };
10519        if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
10520            continue;
10521        }
10522        if !read_reg_string(&subkey, "WinGetInstallerType")
10523            .as_deref()
10524            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
10525        {
10526            continue;
10527        }
10528        return Some(ExistingPortableEntry {
10529            install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
10530            install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
10531            subkey_name: name,
10532        });
10533    }
10534    None
10535}
10536
10537#[cfg(windows)]
10538fn resolve_portable_install_location(
10539    request: &InstallRequest,
10540    existing: &Option<ExistingPortableEntry>,
10541    subkey_name: &str,
10542) -> PathBuf {
10543    resolve_portable_install_location_pure(
10544        request.install_location.as_deref(),
10545        existing
10546            .as_ref()
10547            .and_then(|e| e.install_location.as_deref())
10548            .and_then(Path::to_str),
10549        subkey_name,
10550        &default_user_portable_root(),
10551    )
10552}
10553
10554/// Pure resolution rule for the portable install directory. Priority:
10555/// `request_location` (CLI override) → `existing_location` (so upgrades
10556/// preserve a winget-installed location) → default user portable root joined
10557/// with the package's ARP subkey name.
10558#[cfg(windows)]
10559fn resolve_portable_install_location_pure(
10560    request_location: Option<&str>,
10561    existing_location: Option<&str>,
10562    subkey_name: &str,
10563    default_user_portable_root: &Path,
10564) -> PathBuf {
10565    if let Some(custom) = request_location
10566        && !custom.is_empty()
10567    {
10568        return PathBuf::from(custom);
10569    }
10570    if let Some(loc) = existing_location
10571        && !loc.is_empty()
10572    {
10573        return PathBuf::from(loc);
10574    }
10575    default_user_portable_root.join(subkey_name)
10576}
10577
10578#[cfg(windows)]
10579fn default_user_portable_root() -> PathBuf {
10580    dirs::data_local_dir()
10581        .unwrap_or_else(|| PathBuf::from("."))
10582        .join("Microsoft")
10583        .join("WinGet")
10584        .join("Packages")
10585}
10586
10587#[cfg(windows)]
10588fn clean_directory_contents(dir: &Path) -> Result<()> {
10589    if !dir.is_dir() {
10590        return Ok(());
10591    }
10592    for entry in fs::read_dir(dir)? {
10593        let entry = entry?;
10594        let path = entry.path();
10595        if path.is_dir() {
10596            fs::remove_dir_all(&path)?;
10597        } else {
10598            fs::remove_file(&path)?;
10599        }
10600    }
10601    Ok(())
10602}
10603
10604/// Writes the HKCU ARP entry winget would normally write for a portable
10605/// install. Only the subset that winget's portable list view and pinget's
10606/// `collect_uninstall_view` actually read.
10607#[cfg(windows)]
10608fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
10609    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10610    let uninstall_path = format!(
10611        r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
10612        entry.subkey_name
10613    );
10614    let (subkey, _) = hkcu
10615        .create_subkey(&uninstall_path)
10616        .context("failed to create portable ARP registry subkey")?;
10617
10618    let manifest = entry.manifest;
10619    let install_location_str = entry.install_location.to_string_lossy().to_string();
10620    subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
10621    subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
10622    subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
10623    subkey.set_value("InstallLocation", &install_location_str)?;
10624    subkey.set_value(
10625        "PortableTargetFullPath",
10626        &entry.portable_target_full_path.to_string_lossy().to_string(),
10627    )?;
10628    if let Some(symlink_path) = entry.portable_symlink_full_path {
10629        subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
10630    }
10631    subkey.set_value(
10632        "InstallDirectoryAddedToPath",
10633        &(if entry.added_to_path { 1u32 } else { 0u32 }),
10634    )?;
10635    subkey.set_value(
10636        "InstallDirectoryCreated",
10637        &(if entry.install_directory_created { 1u32 } else { 0u32 }),
10638    )?;
10639    subkey.set_value(
10640        "DisplayName",
10641        &if manifest.name.is_empty() {
10642            manifest.id.clone()
10643        } else {
10644            manifest.name.clone()
10645        },
10646    )?;
10647    subkey.set_value("DisplayVersion", &manifest.version)?;
10648    if let Some(publisher) = manifest.publisher.as_ref() {
10649        subkey.set_value("Publisher", publisher)?;
10650    }
10651    subkey.set_value(
10652        "UninstallString",
10653        &format!("winget uninstall --product-code {}", entry.subkey_name),
10654    )?;
10655    let today = Utc::now().format("%Y%m%d").to_string();
10656    subkey.set_value("InstallDate", &today)?;
10657    if let Some(url) = manifest.package_url.as_ref() {
10658        subkey.set_value("URLInfoAbout", url)?;
10659    }
10660
10661    Ok(())
10662}
10663
10664#[cfg(test)]
10665mod tests {
10666    use std::io::Write;
10667    use std::net::{SocketAddr, TcpListener, TcpStream};
10668    use std::path::{Path, PathBuf};
10669    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
10670    use std::thread::{self, JoinHandle};
10671
10672    use flate2::Compression;
10673    use flate2::write::DeflateEncoder;
10674    use zip::write::SimpleFileOptions;
10675
10676    use super::*;
10677
10678    fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
10679        Manifest {
10680            id: id.to_owned(),
10681            name: name.to_owned(),
10682            version: version.to_owned(),
10683            channel: String::new(),
10684            publisher: Some("Acme".to_owned()),
10685            description: Some("desc".to_owned()),
10686            moniker: None,
10687            package_url: None,
10688            publisher_url: None,
10689            publisher_support_url: None,
10690            license: None,
10691            license_url: None,
10692            privacy_url: None,
10693            author: None,
10694            copyright: None,
10695            copyright_url: None,
10696            release_notes: None,
10697            release_notes_url: None,
10698            tags: Vec::new(),
10699            agreements: Vec::new(),
10700            package_dependencies: Vec::new(),
10701            documentation: Vec::new(),
10702            icons: Vec::new(),
10703            installers: Vec::new(),
10704            require_explicit_upgrade: false,
10705        }
10706    }
10707
10708    #[test]
10709    fn derive_download_filename_matches_winget_pattern() {
10710        let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
10711        let installer = Installer {
10712            architecture: Some("arm64".to_owned()),
10713            installer_type: Some("exe".to_owned()),
10714            scope: Some("user".to_owned()),
10715            locale: Some("en-US".to_owned()),
10716            ..Installer::default()
10717        };
10718        assert_eq!(
10719            derive_download_manifest_filename(&manifest, &installer),
10720            "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
10721        );
10722    }
10723
10724    #[test]
10725    fn derive_download_filename_omits_optional_components() {
10726        let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
10727        let installer = Installer {
10728            architecture: Some("x64".to_owned()),
10729            installer_type: Some("exe".to_owned()),
10730            ..Installer::default()
10731        };
10732        // No scope, no locale → both omitted; architecture remains PascalCase.
10733        assert_eq!(
10734            derive_download_manifest_filename(&manifest, &installer),
10735            "7-Zip_26.01_X64_exe.yaml"
10736        );
10737    }
10738
10739    #[test]
10740    fn derive_download_filename_skips_unknown_version() {
10741        let manifest = sample_manifest("Test.Package", "Unknown", "Test");
10742        let installer = Installer {
10743            architecture: Some("neutral".to_owned()),
10744            installer_type: Some("msi".to_owned()),
10745            ..Installer::default()
10746        };
10747        assert_eq!(
10748            derive_download_manifest_filename(&manifest, &installer),
10749            "Test_Neutral_msi.yaml"
10750        );
10751    }
10752
10753    #[test]
10754    fn derive_download_filename_sanitizes_illegal_chars() {
10755        let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
10756        let installer = Installer {
10757            architecture: Some("x64".to_owned()),
10758            installer_type: Some("exe".to_owned()),
10759            ..Installer::default()
10760        };
10761        // `/` and `:` are illegal in Windows filenames; both must be replaced.
10762        let filename = derive_download_manifest_filename(&manifest, &installer);
10763        assert!(!filename.contains('/') && !filename.contains(':'));
10764        assert!(filename.starts_with("Some_Bad_Name_1.0"));
10765    }
10766
10767    #[test]
10768    fn build_download_manifest_yaml_overrides_identity_fields() {
10769        let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10770        // Documents contain a stale PackageVersion (e.g. empty) — the typed
10771        // manifest's corrected version must win.
10772        let documents = serde_json::json!({
10773            "PackageIdentifier": "stale-id",
10774            "PackageVersion": "",
10775            "PackageName": "Old Name",
10776            "Channel": "preview",
10777            "Installers": [
10778                { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
10779                { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
10780            ],
10781        });
10782        let installer = Installer {
10783            architecture: Some("x64".to_owned()),
10784            installer_type: Some("exe".to_owned()),
10785            url: Some("https://example.com/a".to_owned()),
10786            ..Installer::default()
10787        };
10788        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10789        assert!(yaml.contains("PackageIdentifier: App.Id"));
10790        assert!(yaml.contains("PackageVersion: 2.0.0"));
10791        assert!(yaml.contains("PackageName: App Name"));
10792        assert!(yaml.contains("ManifestType: merged"));
10793        // Channel had a value in docs but the typed manifest's channel is empty
10794        // → expect it stripped, since winget download manifests don't carry it.
10795        assert!(!yaml.contains("Channel: preview"));
10796        // Installers array narrows to the single selected installer.
10797        assert_eq!(yaml.matches("InstallerUrl").count(), 1);
10798        assert!(yaml.contains("https://example.com/a"));
10799        assert!(!yaml.contains("https://example.com/b"));
10800    }
10801
10802    #[test]
10803    fn build_download_manifest_yaml_preserves_typed_channel() {
10804        let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10805        manifest.channel = "beta".to_owned();
10806        let documents = serde_json::json!({
10807            "PackageIdentifier": "App.Id",
10808            "PackageVersion": "2.0.0",
10809            "Installers": [],
10810        });
10811        let installer = Installer::default();
10812        let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10813        assert!(yaml.contains("Channel: beta"));
10814    }
10815
10816    fn temp_app_root(label: &str) -> PathBuf {
10817        static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
10818        std::env::temp_dir().join(format!(
10819            "pinget-rs-tests-{label}-{}-{}",
10820            std::process::id(),
10821            TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
10822        ))
10823    }
10824
10825    #[cfg(windows)]
10826    #[test]
10827    fn path_prefix_check_rejects_string_prefix_collisions() {
10828        // Regression: a naive str::starts_with would falsely consider
10829        // C:\Foobar a child of C:\Foo and let the Links\ sweep delete unrelated
10830        // symlinks. The component-aware check must reject these.
10831        assert!(!path_starts_with_case_insensitive(
10832            Path::new(r"C:\Foobar\bin.exe"),
10833            Path::new(r"C:\Foo"),
10834        ));
10835        assert!(!path_starts_with_case_insensitive(
10836            Path::new(r"C:\Foo\bin.exe.bak"),
10837            Path::new(r"C:\Foo\bin.exe"),
10838        ));
10839    }
10840
10841    #[cfg(windows)]
10842    #[test]
10843    fn path_prefix_check_accepts_real_children_and_self() {
10844        // Dir install: the symlink target lives inside the install dir.
10845        assert!(path_starts_with_case_insensitive(
10846            Path::new(r"C:\Foo\sub\bin.exe"),
10847            Path::new(r"C:\Foo"),
10848        ));
10849        // File install: the symlink points at the same file we removed.
10850        assert!(path_starts_with_case_insensitive(
10851            Path::new(r"C:\Foo\bin.exe"),
10852            Path::new(r"C:\Foo\bin.exe"),
10853        ));
10854    }
10855
10856    #[cfg(windows)]
10857    #[test]
10858    fn path_prefix_check_is_case_insensitive() {
10859        // Windows paths are case-insensitive — winget and pinget may write
10860        // them with different casing.
10861        assert!(path_starts_with_case_insensitive(
10862            Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
10863            Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
10864        ));
10865    }
10866
10867    #[cfg(windows)]
10868    #[test]
10869    fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
10870        let query = PackageQuery {
10871            source: Some("winget".to_owned()),
10872            ..PackageQuery::default()
10873        };
10874        assert_eq!(
10875            portable_source_identifier(&query),
10876            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10877        );
10878        // Case-insensitive — manifests/catalog may report differently.
10879        let query = PackageQuery {
10880            source: Some("WINGET".to_owned()),
10881            ..PackageQuery::default()
10882        };
10883        assert_eq!(
10884            portable_source_identifier(&query),
10885            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10886        );
10887    }
10888
10889    #[cfg(windows)]
10890    #[test]
10891    fn portable_source_identifier_passes_through_custom_sources() {
10892        let query = PackageQuery {
10893            source: Some("internal-feed".to_owned()),
10894            ..PackageQuery::default()
10895        };
10896        assert_eq!(portable_source_identifier(&query), "internal-feed");
10897    }
10898
10899    #[cfg(windows)]
10900    #[test]
10901    fn portable_source_identifier_defaults_for_missing_or_empty_source() {
10902        let query = PackageQuery {
10903            source: None,
10904            ..PackageQuery::default()
10905        };
10906        assert_eq!(
10907            portable_source_identifier(&query),
10908            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10909        );
10910        let query = PackageQuery {
10911            source: Some(String::new()),
10912            ..PackageQuery::default()
10913        };
10914        assert_eq!(
10915            portable_source_identifier(&query),
10916            "Microsoft.Winget.Source_8wekyb3d8bbwe"
10917        );
10918    }
10919
10920    #[cfg(windows)]
10921    #[test]
10922    fn portable_subkey_name_matches_winget_format() {
10923        assert_eq!(
10924            portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
10925            "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
10926        );
10927    }
10928
10929    #[cfg(windows)]
10930    #[test]
10931    fn determine_portable_alias_prefers_nested_installer_alias() {
10932        let installer = Installer {
10933            nested_installer_files: vec![NestedInstallerFile {
10934                relative_file_path: "ripgrep/rg.exe".to_owned(),
10935                portable_command_alias: Some("rg".to_owned()),
10936            }],
10937            commands: vec!["other-name".to_owned()],
10938            ..Installer::default()
10939        };
10940        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
10941    }
10942
10943    #[cfg(windows)]
10944    #[test]
10945    fn determine_portable_alias_falls_back_to_commands() {
10946        let installer = Installer {
10947            // No nested files at all (standalone portable case).
10948            commands: vec!["nuget".to_owned()],
10949            ..Installer::default()
10950        };
10951        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
10952
10953        // Nested files exist but none declare PortableCommandAlias.
10954        let installer = Installer {
10955            nested_installer_files: vec![NestedInstallerFile {
10956                relative_file_path: "subdir/bin.exe".to_owned(),
10957                portable_command_alias: None,
10958            }],
10959            commands: vec!["bin".to_owned()],
10960            ..Installer::default()
10961        };
10962        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
10963    }
10964
10965    #[cfg(windows)]
10966    #[test]
10967    fn determine_portable_alias_returns_none_without_alias_or_commands() {
10968        let installer = Installer::default();
10969        assert!(determine_portable_alias(&installer).is_none());
10970    }
10971
10972    #[cfg(windows)]
10973    #[test]
10974    fn resolve_portable_install_location_prefers_request_override() {
10975        let result = resolve_portable_install_location_pure(
10976            Some(r"D:\Tools\rg"),
10977            Some(r"C:\Software\rg-test"),
10978            "Sub_Source",
10979            Path::new(r"C:\default"),
10980        );
10981        assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
10982    }
10983
10984    #[cfg(windows)]
10985    #[test]
10986    fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
10987        // The #4823 scenario: pinget upgrade with no explicit --location must
10988        // resolve to the install_location the prior install recorded, not the
10989        // default portable root.
10990        let result = resolve_portable_install_location_pure(
10991            None,
10992            Some(r"C:\Software\rg-test"),
10993            "Sub_Source",
10994            Path::new(r"C:\default"),
10995        );
10996        assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
10997    }
10998
10999    #[cfg(windows)]
11000    #[test]
11001    fn resolve_portable_install_location_falls_back_to_default_root() {
11002        let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
11003        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
11004    }
11005
11006    #[cfg(windows)]
11007    #[test]
11008    fn resolve_portable_install_location_treats_empty_strings_as_unset() {
11009        // Defensive: PackageQuery / registry reads can yield "" instead of
11010        // None for missing values.
11011        let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
11012        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
11013    }
11014
11015    #[cfg(windows)]
11016    #[test]
11017    fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
11018        let root = std::env::temp_dir().join(format!(
11019            "pinget-clean-test-{}",
11020            Utc::now().timestamp_nanos_opt().unwrap_or_default()
11021        ));
11022        fs::create_dir_all(&root).expect("create temp dir");
11023        fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
11024        fs::create_dir_all(root.join("nested")).expect("create nested dir");
11025        fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
11026
11027        clean_directory_contents(&root).expect("clean root contents");
11028
11029        assert!(root.is_dir(), "root dir itself must survive");
11030        assert_eq!(
11031            fs::read_dir(&root).expect("read root").count(),
11032            0,
11033            "root must be empty after clean"
11034        );
11035        let _ = fs::remove_dir_all(&root);
11036    }
11037
11038    #[cfg(windows)]
11039    #[test]
11040    fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
11041        let bogus = std::env::temp_dir().join(format!(
11042            "pinget-clean-test-bogus-{}",
11043            Utc::now().timestamp_nanos_opt().unwrap_or_default()
11044        ));
11045        assert!(!bogus.exists());
11046        clean_directory_contents(&bogus).expect("clean nonexistent dir");
11047        // Should not have created the dir.
11048        assert!(!bogus.exists());
11049    }
11050
11051    #[cfg(windows)]
11052    #[test]
11053    fn path_prefix_check_handles_mixed_separators() {
11054        // NestedInstallerFiles RelativeFilePath uses '/' in manifests, so the
11055        // path we joined can end up mixed-separator on Windows.
11056        assert!(path_starts_with_case_insensitive(
11057            Path::new(r"C:\Foo\sub/bin.exe"),
11058            Path::new(r"C:\Foo"),
11059        ));
11060    }
11061
11062    #[test]
11063    fn repository_options_capture_custom_host_settings() {
11064        let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
11065            .with_user_agent("pinget-rs-tests/1.0")
11066            .with_pre_indexed_source_auto_update_interval(None);
11067
11068        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
11069        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
11070        assert!(options.pre_indexed_source_auto_update_interval.is_none());
11071    }
11072
11073    #[test]
11074    fn dependency_install_requests_are_silent_and_not_forced() {
11075        let mut parent = InstallRequest::new(PackageQuery {
11076            source: Some("winget".to_owned()),
11077            ..PackageQuery::default()
11078        });
11079        parent.mode = InstallerMode::SilentWithProgress;
11080        parent.force = true;
11081        parent.accept_package_agreements = true;
11082        parent.ignore_security_hash = true;
11083        parent.dependency_source = Some("dependencies".to_owned());
11084
11085        let dependency =
11086            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
11087
11088        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
11089        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
11090        assert_eq!(dependency.mode, InstallerMode::Silent);
11091        assert!(!dependency.force);
11092        assert!(dependency.no_upgrade);
11093        assert!(dependency.accept_package_agreements);
11094        assert!(dependency.ignore_security_hash);
11095    }
11096
11097    #[test]
11098    fn storage_paths_use_configured_app_root() {
11099        let app_root = PathBuf::from(r"C:\temp\pinget-test");
11100        let source = SourceRecord {
11101            name: "winget/test".to_owned(),
11102            kind: SourceKind::PreIndexed,
11103            arg: "https://example.com/cache".to_owned(),
11104            identifier: "Test.Source".to_owned(),
11105            trust_level: "None".to_owned(),
11106            explicit: false,
11107            priority: 0,
11108            last_update: None,
11109            source_version: None,
11110            etag: None,
11111            last_modified: None,
11112        };
11113
11114        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
11115        assert_eq!(
11116            source_state_dir(&app_root, &source),
11117            app_root.join("sources").join("winget_test")
11118        );
11119        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
11120    }
11121
11122    #[cfg(windows)]
11123    #[test]
11124    fn packaged_layout_paths_resolve_from_packaged_app_root() {
11125        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
11126        let app_root = local_app_data
11127            .join("Packages")
11128            .join(PACKAGED_FAMILY_NAME)
11129            .join("LocalState");
11130        let source = SourceStore::default()
11131            .sources
11132            .into_iter()
11133            .find(|source| source.name == "winget")
11134            .expect("winget source");
11135        assert!(uses_packaged_layout(&app_root));
11136        assert!(
11137            user_settings_path(&app_root)
11138                .display()
11139                .to_string()
11140                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
11141        );
11142        assert!(
11143            packaged_file_cache_root(&app_root)
11144                .display()
11145                .to_string()
11146                .ends_with(&format!(
11147                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
11148                    PACKAGED_FAMILY_NAME
11149                ))
11150        );
11151        assert!(
11152            source_state_dir(&app_root, &source)
11153                .display()
11154                .to_string()
11155                .ends_with(&format!(
11156                    r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe",
11157                    PACKAGED_FAMILY_NAME
11158                ))
11159        );
11160        assert!(
11161            source_installed_db_path_with_keying(&app_root, &source, false)
11162                .display()
11163                .to_string()
11164                .ends_with(&format!(
11165                    r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db",
11166                    PACKAGED_FAMILY_NAME
11167                ))
11168        );
11169    }
11170
11171    #[test]
11172    fn source_mode_parses_accepted_spellings() {
11173        assert_eq!(SourceMode::parse("auto"), Some(SourceMode::Auto));
11174        assert_eq!(SourceMode::parse("  Auto  "), Some(SourceMode::Auto));
11175        assert_eq!(SourceMode::parse("PRIVATE"), Some(SourceMode::Private));
11176        assert_eq!(
11177            SourceMode::parse("system-winget-mirror"),
11178            Some(SourceMode::SystemWingetMirror)
11179        );
11180        assert_eq!(
11181            SourceMode::parse("SystemWingetMirror"),
11182            Some(SourceMode::SystemWingetMirror)
11183        );
11184    }
11185
11186    #[test]
11187    fn source_mode_rejects_unknown_spellings() {
11188        assert_eq!(SourceMode::parse(""), None);
11189        assert_eq!(SourceMode::parse("mirror"), None);
11190        assert_eq!(SourceMode::parse("system_winget_mirror"), None);
11191    }
11192
11193    #[test]
11194    fn source_mode_resolution_keeps_private_default_for_a_custom_app_root() {
11195        assert_eq!(SourceMode::resolve(None, true), SourceMode::Private);
11196        assert_eq!(SourceMode::resolve(None, false), SourceMode::Auto);
11197    }
11198
11199    #[test]
11200    fn source_mode_resolution_lets_the_environment_override_a_custom_app_root() {
11201        assert_eq!(SourceMode::resolve(Some(SourceMode::Auto), true), SourceMode::Auto);
11202        assert_eq!(
11203            SourceMode::resolve(Some(SourceMode::SystemWingetMirror), true),
11204            SourceMode::SystemWingetMirror
11205        );
11206        assert_eq!(
11207            SourceMode::resolve(Some(SourceMode::Private), false),
11208            SourceMode::Private
11209        );
11210    }
11211
11212    #[cfg(windows)]
11213    #[test]
11214    fn default_app_root_outside_package_avoids_packaged_layout() {
11215        // Tests run without AppX package identity, so the default app root must not
11216        // resolve to the WinGet packaged LocalState — that location requires a
11217        // brokered/elevated writer for its secure-settings stream.
11218        let prior = std::env::var_os(APP_ROOT_ENV_VAR);
11219        // SAFETY: tests in this module are not run concurrently with other env mutators.
11220        unsafe { std::env::remove_var(APP_ROOT_ENV_VAR) };
11221
11222        let result = default_app_root();
11223
11224        if let Some(prior) = prior {
11225            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
11226            unsafe { std::env::set_var(APP_ROOT_ENV_VAR, prior) };
11227        }
11228
11229        let app_root = result.expect("default app root");
11230        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
11231        assert!(
11232            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
11233            "unexpected fallback app root: {app_root:?}"
11234        );
11235    }
11236
11237    #[test]
11238    fn packaged_source_stream_overlays_defaults_and_metadata() {
11239        let store = parse_packaged_source_store(
11240            Some(
11241                "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",
11242            ),
11243            Some(
11244                "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",
11245            ),
11246        )
11247        .expect("packaged store");
11248
11249        assert!(!store.sources.iter().any(|source| source.name == "winget"));
11250        let corp = store
11251            .sources
11252            .iter()
11253            .find(|source| source.name == "corp")
11254            .expect("corp source");
11255        assert_eq!(corp.kind, SourceKind::Rest);
11256        assert_eq!(corp.identifier, "Contoso.Rest");
11257        assert_eq!(corp.trust_level, "Trusted");
11258        assert!(corp.explicit);
11259        assert_eq!(corp.priority, 7);
11260        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
11261        assert_eq!(corp.etag.as_deref(), Some("etag-v1"));
11262        assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT"));
11263        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
11264    }
11265
11266    #[test]
11267    fn system_winget_export_parses_json_lines() {
11268        let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11269{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11270{"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"}
11271{"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"}
11272"#;
11273
11274        let sources = parse_system_winget_source_export(output).expect("source export");
11275
11276        assert_eq!(
11277            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
11278            vec!["winget.pro", "msstore", "winget", "winget-font"]
11279        );
11280        assert_eq!(sources[0].kind, SourceKind::Rest);
11281        assert_eq!(sources[0].arg, "https://api.winget.pro/feed");
11282        assert_eq!(sources[0].identifier, "api.winget.pro");
11283        assert_eq!(sources[0].trust_level, "Trusted");
11284        assert!(!sources[0].explicit);
11285        assert_eq!(sources[2].kind, SourceKind::PreIndexed);
11286        assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe");
11287        assert!(!sources[2].explicit);
11288    }
11289
11290    #[test]
11291    fn system_winget_export_parses_wrapped_sources() {
11292        let output = r#"{
11293  "Sources": [
11294    {
11295      "Name": "corp",
11296      "Type": "Microsoft.Rest",
11297      "Arg": "https://packages.contoso.test/api",
11298      "Identifier": "Contoso.Rest",
11299      "TrustLevel": "Trusted",
11300      "Explicit": true,
11301      "Priority": 9
11302    }
11303  ]
11304}"#;
11305
11306        let sources = parse_system_winget_source_export(output).expect("source export");
11307        let source = sources.first().expect("source");
11308        assert_eq!(sources.len(), 1);
11309        assert_eq!(source.name, "corp");
11310        assert_eq!(source.kind, SourceKind::Rest);
11311        assert_eq!(source.identifier, "Contoso.Rest");
11312        assert_eq!(source.trust_level, "Trusted");
11313        assert!(source.explicit);
11314        assert_eq!(source.priority, 9);
11315    }
11316
11317    #[test]
11318    fn system_winget_source_store_detects_secure_settings_stub() {
11319        assert!(is_secure_settings_stub(Some(
11320            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
11321        )));
11322        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
11323    }
11324
11325    #[test]
11326    fn system_winget_source_store_builds_add_arguments() {
11327        let args = build_winget_source_add_arguments(
11328            "corp",
11329            "https://packages.contoso.test/api",
11330            SourceKind::Rest,
11331            "Trusted",
11332            true,
11333        );
11334
11335        assert_eq!(
11336            args,
11337            vec![
11338                "source",
11339                "add",
11340                "--name",
11341                "corp",
11342                "--arg",
11343                "https://packages.contoso.test/api",
11344                "--type",
11345                "Microsoft.Rest",
11346                "--disable-interactivity",
11347                "--trust-level",
11348                "trusted",
11349                "--explicit",
11350            ]
11351        );
11352    }
11353
11354    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
11355        Ok(WingetSourceCommandResult {
11356            exit_code: 0,
11357            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11358"#
11359            .to_owned(),
11360            stderr: String::new(),
11361        })
11362    }
11363
11364    #[test]
11365    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
11366        let original_runner = {
11367            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11368            let original = *runner;
11369            *runner = fake_system_winget_source_export;
11370            original
11371        };
11372
11373        let result = load_packaged_store_from_streams(
11374            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
11375            None,
11376        );
11377
11378        {
11379            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11380            *runner = original_runner;
11381        }
11382
11383        let store = result.expect("packaged store").expect("packaged store");
11384        let source = store.sources.first().expect("source");
11385        assert_eq!(store.sources.len(), 1);
11386        assert_eq!(source.name, "contoso");
11387        assert_eq!(source.kind, SourceKind::Rest);
11388    }
11389
11390    #[test]
11391    fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() {
11392        let original_runner = {
11393            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11394            let original = *runner;
11395            *runner = fake_system_winget_source_export;
11396            original
11397        };
11398
11399        let app_root = temp_app_root("system-winget-mirror");
11400        let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror);
11401
11402        {
11403            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11404            *runner = original_runner;
11405        }
11406
11407        let (mode, mut store) = result.expect("mirror store");
11408        assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror);
11409        assert!(system_winget_mirror_store_path(&app_root).exists());
11410        assert!(
11411            !store_path(&app_root).exists(),
11412            "mirror mode must not write private sources.json"
11413        );
11414
11415        let source = store.sources.first_mut().expect("source");
11416        source.last_update = Some(Utc::now());
11417        source.source_version = Some("cached-contract".to_owned());
11418        source.etag = Some("\"etag\"".to_owned());
11419        source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
11420        save_system_winget_mirror_store(&app_root, &store).expect("save mirror");
11421
11422        let original_runner = {
11423            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11424            let original = *runner;
11425            *runner = fake_system_winget_source_export;
11426            original
11427        };
11428        let refreshed = refresh_system_winget_mirror_store(&app_root);
11429        {
11430            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11431            *runner = original_runner;
11432        }
11433
11434        let refreshed = refreshed.expect("refresh mirror");
11435        let source = refreshed.sources.first().expect("source");
11436        assert_eq!(source.source_version.as_deref(), Some("cached-contract"));
11437        assert_eq!(source.etag.as_deref(), Some("\"etag\""));
11438        assert_eq!(
11439            source_state_dir_with_keying(&app_root, source, true),
11440            app_root.join("sources").join("api.contoso.test")
11441        );
11442        assert_eq!(
11443            source_state_dir_with_keying(&app_root, source, false),
11444            app_root.join("sources").join("contoso")
11445        );
11446
11447        let _ = fs::remove_dir_all(&app_root);
11448    }
11449
11450    #[test]
11451    fn source_metadata_and_named_reset_round_trip() {
11452        let app_root = temp_app_root("source-reset");
11453        let result = (|| -> Result<()> {
11454            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11455            repository.add_source_with_metadata(
11456                "test",
11457                "https://example.com/test",
11458                SourceKind::Rest,
11459                Some("trusted"),
11460                true,
11461                4,
11462            )?;
11463
11464            repository.edit_source("test", Some(false), None)?;
11465            let edited = repository
11466                .list_sources()
11467                .into_iter()
11468                .find(|source| source.name == "test")
11469                .expect("source");
11470            assert_eq!(edited.trust_level, "Trusted");
11471            assert!(!edited.explicit);
11472            assert_eq!(edited.priority, 4);
11473
11474            repository.reset_source("test")?;
11475            let reset = repository
11476                .list_sources()
11477                .into_iter()
11478                .find(|source| source.name == "test")
11479                .expect("source");
11480            assert_eq!(reset.trust_level, "Trusted");
11481            assert!(!reset.explicit);
11482            assert_eq!(reset.priority, 4);
11483            assert!(reset.last_update.is_none());
11484            assert!(reset.source_version.is_none());
11485            Ok(())
11486        })();
11487
11488        let _ = fs::remove_dir_all(&app_root);
11489        result.expect("source metadata");
11490    }
11491
11492    #[test]
11493    fn explicit_sources_are_skipped_by_default_search_resolution() {
11494        let app_root = temp_app_root("source-resolution");
11495        let result = (|| -> Result<()> {
11496            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11497            repository.add_source_with_metadata(
11498                "explicit-test",
11499                "https://example.com/explicit",
11500                SourceKind::Rest,
11501                Some("trusted"),
11502                true,
11503                10,
11504            )?;
11505
11506            let indexes = repository.resolve_source_indexes(None)?;
11507            let names: Vec<_> = indexes
11508                .into_iter()
11509                .map(|index| repository.list_sources()[index].name.clone())
11510                .collect();
11511            assert!(!names.iter().any(|name| name == "explicit-test"));
11512            Ok(())
11513        })();
11514
11515        let _ = fs::remove_dir_all(&app_root);
11516        result.expect("source resolution");
11517    }
11518
11519    #[test]
11520    fn source_resolution_accepts_source_identifier() {
11521        let app_root = temp_app_root("source-identifier-resolution");
11522        let result = (|| -> Result<()> {
11523            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11524            repository.add_source_with_metadata(
11525                "winget.pro",
11526                "https://api.winget.pro/feed",
11527                SourceKind::Rest,
11528                Some("trusted"),
11529                false,
11530                0,
11531            )?;
11532            repository.store.sources[0].identifier = "api.winget.pro".to_owned();
11533
11534            let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?;
11535            assert_eq!(indexes, vec![0]);
11536            Ok(())
11537        })();
11538
11539        let _ = fs::remove_dir_all(&app_root);
11540        result.expect("source identifier resolution");
11541    }
11542
11543    #[test]
11544    fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() {
11545        let query = PackageQuery {
11546            id: Some("tessl.tessl".to_owned()),
11547            source: Some("winget".to_owned()),
11548            exact: true,
11549            ..PackageQuery::default()
11550        };
11551        let clean_miss = SearchLocatedResult {
11552            matches: Vec::new(),
11553            warnings: Vec::new(),
11554            failures: Vec::new(),
11555            truncated: false,
11556        };
11557        assert!(should_retry_show_across_system_winget_sources(
11558            true,
11559            &query,
11560            &clean_miss
11561        ));
11562
11563        let private_mode = false;
11564        assert!(!should_retry_show_across_system_winget_sources(
11565            private_mode,
11566            &query,
11567            &clean_miss
11568        ));
11569
11570        let explicit_custom_source = PackageQuery {
11571            source: Some("winget.pro".to_owned()),
11572            ..query.clone()
11573        };
11574        assert!(!should_retry_show_across_system_winget_sources(
11575            true,
11576            &explicit_custom_source,
11577            &clean_miss
11578        ));
11579
11580        let failed_source = SearchLocatedResult {
11581            failures: vec![SourceSearchFailure {
11582                source_name: "winget".to_owned(),
11583                message: "network failure".to_owned(),
11584            }],
11585            ..clean_miss
11586        };
11587        assert!(!should_retry_show_across_system_winget_sources(
11588            true,
11589            &query,
11590            &failed_source
11591        ));
11592    }
11593
11594    #[test]
11595    fn user_and_admin_settings_round_trip() {
11596        let app_root = temp_app_root("settings");
11597        let result = (|| -> Result<()> {
11598            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11599            repository.set_user_settings(
11600                &serde_json::json!({
11601                    "visual": {
11602                        "progressBar": "retro"
11603                    }
11604                }),
11605                false,
11606            )?;
11607            repository.set_user_settings(
11608                &serde_json::json!({
11609                    "experimentalFeatures": {
11610                        "directMSI": true
11611                    }
11612                }),
11613                true,
11614            )?;
11615
11616            let user_settings = repository.get_user_settings()?;
11617            assert_eq!(
11618                user_settings["visual"]["progressBar"],
11619                serde_json::Value::String("retro".to_owned())
11620            );
11621            assert_eq!(
11622                user_settings["experimentalFeatures"]["directMSI"],
11623                serde_json::Value::Bool(true)
11624            );
11625            assert!(repository.test_user_settings(
11626                &serde_json::json!({
11627                    "experimentalFeatures": {
11628                        "directMSI": true
11629                    }
11630                }),
11631                true
11632            )?);
11633
11634            repository.set_admin_setting("LocalManifestFiles", true)?;
11635            repository.set_admin_setting("InstallerHashOverride", true)?;
11636            let admin_settings = repository.get_admin_settings()?;
11637            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
11638            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
11639
11640            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
11641            let reset_one = repository.get_admin_settings()?;
11642            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
11643
11644            repository.reset_admin_setting(None, true)?;
11645            let reset_all = repository.get_admin_settings()?;
11646            for name in Repository::supported_admin_settings() {
11647                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
11648            }
11649            Ok(())
11650        })();
11651
11652        let _ = fs::remove_dir_all(&app_root);
11653        result.expect("settings");
11654    }
11655
11656    #[test]
11657    fn show_result_structured_document_is_manifest_oriented() {
11658        let result = ShowResult {
11659            package: SearchMatch {
11660                source_name: "winget".to_owned(),
11661                source_kind: SourceKind::PreIndexed,
11662                id: "Test.Package".to_owned(),
11663                name: "Test Package".to_owned(),
11664                moniker: Some("testpkg".to_owned()),
11665                version: Some("1.2.3".to_owned()),
11666                channel: Some("stable".to_owned()),
11667                match_criteria: Some("Id".to_owned()),
11668            },
11669            manifest: Manifest {
11670                id: "Test.Package".to_owned(),
11671                name: "Test Package".to_owned(),
11672                version: "1.2.3".to_owned(),
11673                channel: "stable".to_owned(),
11674                publisher: Some("Contoso".to_owned()),
11675                description: Some("Structured output".to_owned()),
11676                moniker: Some("testpkg".to_owned()),
11677                package_url: None,
11678                publisher_url: None,
11679                publisher_support_url: None,
11680                license: None,
11681                license_url: None,
11682                privacy_url: None,
11683                author: None,
11684                copyright: None,
11685                copyright_url: None,
11686                release_notes: None,
11687                release_notes_url: None,
11688                tags: vec!["utility".to_owned()],
11689                agreements: Vec::new(),
11690                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
11691                documentation: vec![Documentation {
11692                    label: Some("Docs".to_owned()),
11693                    url: "https://example.test/docs".to_owned(),
11694                }],
11695                icons: vec![PackageIcon {
11696                    icon_url: Some("https://example.test/default-256.ico".to_owned()),
11697                    icon_file_type: Some("ico".to_owned()),
11698                    icon_resolution: Some("256x256".to_owned()),
11699                    icon_theme: Some("default".to_owned()),
11700                    icon_sha256: Some("DEF456".to_owned()),
11701                }],
11702                installers: vec![Installer {
11703                    architecture: Some("x64".to_owned()),
11704                    installer_type: Some("msix".to_owned()),
11705                    url: Some("https://example.test/Test.Package.msix".to_owned()),
11706                    sha256: Some("ABC123".to_owned()),
11707                    product_code: None,
11708                    locale: Some("en-US".to_owned()),
11709                    scope: Some("machine".to_owned()),
11710                    release_date: None,
11711                    package_family_name: None,
11712                    upgrade_code: None,
11713                    platforms: Vec::new(),
11714                    minimum_os_version: None,
11715                    switches: InstallerSwitches {
11716                        silent: Some("/quiet".to_owned()),
11717                        ..InstallerSwitches::default()
11718                    },
11719                    commands: vec!["testpkg".to_owned()],
11720                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11721                    require_explicit_upgrade: false,
11722                    nested_installer_type: None,
11723                    nested_installer_files: Vec::new(),
11724                }],
11725                require_explicit_upgrade: false,
11726            },
11727            selected_installer: Some(Installer {
11728                architecture: Some("x64".to_owned()),
11729                installer_type: Some("msix".to_owned()),
11730                url: Some("https://example.test/Test.Package.msix".to_owned()),
11731                sha256: Some("ABC123".to_owned()),
11732                product_code: None,
11733                locale: Some("en-US".to_owned()),
11734                scope: Some("machine".to_owned()),
11735                release_date: None,
11736                package_family_name: None,
11737                upgrade_code: None,
11738                platforms: Vec::new(),
11739                minimum_os_version: None,
11740                switches: InstallerSwitches {
11741                    silent: Some("/quiet".to_owned()),
11742                    ..InstallerSwitches::default()
11743                },
11744                commands: vec!["testpkg".to_owned()],
11745                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11746                require_explicit_upgrade: false,
11747                nested_installer_type: None,
11748                nested_installer_files: Vec::new(),
11749            }),
11750            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
11751            warnings: vec!["cache warmed".to_owned()],
11752            manifest_documents: JsonValue::Array(vec![
11753                serde_json::json!({
11754                    "PackageIdentifier": "Test.Package",
11755                    "PackageVersion": "1.2.3",
11756                    "DefaultLocale": "en-US",
11757                    "ManifestType": "version",
11758                    "ManifestVersion": "1.10.0"
11759                }),
11760                serde_json::json!({
11761                    "PackageIdentifier": "Test.Package",
11762                    "PackageVersion": "1.2.3",
11763                    "PackageLocale": "en-US",
11764                    "PackageName": "Test Package",
11765                    "Publisher": "Example",
11766                    "License": "MIT",
11767                    "ShortDescription": "Structured output",
11768                    "Icons": [
11769                        {
11770                            "IconUrl": "https://example.test/default-256.ico",
11771                            "IconFileType": "ico",
11772                            "IconResolution": "256x256",
11773                            "IconTheme": "default",
11774                            "IconSha256": "DEF456"
11775                        }
11776                    ],
11777                    "ManifestType": "defaultLocale",
11778                    "ManifestVersion": "1.10.0"
11779                }),
11780                serde_json::json!({
11781                    "PackageIdentifier": "Test.Package",
11782                    "PackageVersion": "1.2.3",
11783                    "ManifestType": "installer",
11784                    "ManifestVersion": "1.10.0",
11785                    "Installers": [
11786                        {
11787                            "Architecture": "x64",
11788                            "InstallerType": "msix",
11789                            "InstallerUrl": "https://example.test/Test.Package.msix",
11790                            "InstallerSha256": "ABC123",
11791                            "Commands": ["testpkg"],
11792                            "InstallerSwitches": { "Silent": "/quiet" },
11793                            "Dependencies": {
11794                                "PackageDependencies": [
11795                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
11796                                ]
11797                            }
11798                        }
11799                    ]
11800                }),
11801            ]),
11802        };
11803
11804        let document = result.structured_document();
11805
11806        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
11807        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
11808        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
11809        assert_eq!(
11810            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
11811            Some("Microsoft.VCRedist.2015+.x64")
11812        );
11813        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
11814        assert_eq!(
11815            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
11816            Some("/quiet")
11817        );
11818        assert_eq!(
11819            document["IconUrl"].as_str(),
11820            Some("https://example.test/default-256.ico")
11821        );
11822        assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
11823        assert_eq!(
11824            document["Icons"][0]["IconUrl"].as_str(),
11825            Some("https://example.test/default-256.ico")
11826        );
11827    }
11828
11829    #[test]
11830    fn manifest_parses_require_explicit_upgrade_at_top_level() {
11831        // Top-level RequireExplicitUpgrade flag should propagate to
11832        // Manifest.require_explicit_upgrade. winget catalogs typically
11833        // place the flag here for browser packages and similar
11834        // self-updating apps that opt out of bulk `upgrade`.
11835        let yaml = r#"
11836PackageIdentifier: Test.Package
11837PackageVersion: 1.2.3
11838DefaultLocale: en-US
11839ManifestType: singleton
11840ManifestVersion: 1.10.0
11841PackageLocale: en-US
11842PackageName: Test Package
11843Publisher: Example
11844License: MIT
11845ShortDescription: explicit-upgrade fixture
11846RequireExplicitUpgrade: true
11847Installers:
11848  - Architecture: x64
11849    InstallerType: exe
11850    InstallerUrl: https://example.test/Test.Package.exe
11851    InstallerSha256: ABC123
11852"#;
11853        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11854        assert!(manifest.require_explicit_upgrade);
11855    }
11856
11857    #[test]
11858    fn manifest_parses_require_explicit_upgrade_on_installer() {
11859        // Per-installer flag — only one of several installers declares it,
11860        // but the Manifest aggregate must still be `true` because the user
11861        // could pick that installer when upgrading.
11862        let yaml = r#"
11863PackageIdentifier: Test.Package
11864PackageVersion: 1.2.3
11865DefaultLocale: en-US
11866ManifestType: singleton
11867ManifestVersion: 1.10.0
11868PackageLocale: en-US
11869PackageName: Test Package
11870Publisher: Example
11871License: MIT
11872ShortDescription: explicit-upgrade fixture
11873Installers:
11874  - Architecture: x64
11875    InstallerType: exe
11876    InstallerUrl: https://example.test/Test.Package.x64.exe
11877    InstallerSha256: ABC123
11878  - Architecture: arm64
11879    InstallerType: exe
11880    InstallerUrl: https://example.test/Test.Package.arm64.exe
11881    InstallerSha256: DEF456
11882    RequireExplicitUpgrade: true
11883"#;
11884        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11885        assert!(manifest.require_explicit_upgrade);
11886        assert!(!manifest.installers[0].require_explicit_upgrade);
11887        assert!(manifest.installers[1].require_explicit_upgrade);
11888    }
11889
11890    #[test]
11891    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
11892        let yaml = r#"
11893PackageIdentifier: Test.Package
11894PackageVersion: 1.2.3
11895DefaultLocale: en-US
11896ManifestType: singleton
11897ManifestVersion: 1.10.0
11898PackageLocale: en-US
11899PackageName: Test Package
11900Publisher: Example
11901License: MIT
11902ShortDescription: baseline fixture
11903Installers:
11904  - Architecture: x64
11905    InstallerType: exe
11906    InstallerUrl: https://example.test/Test.Package.exe
11907    InstallerSha256: ABC123
11908"#;
11909        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11910        assert!(!manifest.require_explicit_upgrade);
11911    }
11912
11913    #[test]
11914    fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
11915        let yaml = r#"
11916PackageIdentifier: Test.Package
11917PackageVersion: 1.2.3
11918DefaultLocale: en-US
11919ManifestType: singleton
11920ManifestVersion: 1.10.0
11921PackageLocale: en-US
11922PackageName: Test Package
11923Publisher: Example
11924License: MIT
11925ShortDescription: icon fixture
11926Icons:
11927  - IconUrl: https://example.test/dark-512.png
11928    IconFileType: png
11929    IconResolution: 512x512
11930    IconTheme: dark
11931    IconSha256: DARK
11932  - IconUrl: https://example.test/default-64.png
11933    IconFileType: png
11934    IconResolution: 64x64
11935    IconTheme: default
11936  - IconUrl: https://example.test/default-256.ico
11937    IconFileType: ico
11938    IconResolution: 256x256
11939    IconTheme: default
11940  - IconFileType: png
11941    IconResolution: 1024x1024
11942Installers:
11943  - Architecture: x64
11944    InstallerType: exe
11945    InstallerUrl: https://example.test/Test.Package.exe
11946    InstallerSha256: ABC123
11947"#;
11948        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11949
11950        assert_eq!(manifest.icons.len(), 4);
11951        assert_eq!(
11952            manifest.icons[0].icon_url.as_deref(),
11953            Some("https://example.test/dark-512.png")
11954        );
11955        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11956        let selected = manifest.icon().expect("selected icon");
11957        assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
11958        assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
11959    }
11960
11961    #[test]
11962    fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
11963        let yaml = r#"
11964PackageIdentifier: Test.Package
11965PackageVersion: 1.2.3
11966DefaultLocale: en-US
11967ManifestType: singleton
11968ManifestVersion: 1.10.0
11969PackageLocale: en-US
11970PackageName: Test Package
11971Publisher: Example
11972License: MIT
11973ShortDescription: no-icon fixture
11974Installers:
11975  - Architecture: x64
11976    InstallerType: exe
11977    InstallerUrl: https://example.test/Test.Package.exe
11978    InstallerSha256: ABC123
11979"#;
11980        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11981
11982        assert!(manifest.icons.is_empty());
11983        assert!(manifest.icon().is_none());
11984        assert!(manifest.icon_url().is_none());
11985    }
11986
11987    #[test]
11988    fn rest_manifest_parses_icons_from_default_locale() {
11989        let response = serde_json::json!({
11990            "Data": {
11991                "PackageIdentifier": "Test.Package",
11992                "Versions": [
11993                    {
11994                        "PackageVersion": "1.2.3",
11995                        "DefaultLocale": {
11996                            "PackageName": "Test Package",
11997                            "Publisher": "Example",
11998                            "ShortDescription": "REST icon fixture",
11999                            "Icons": [
12000                                {
12001                                    "IconUrl": "https://example.test/default-128.png",
12002                                    "IconFileType": "png",
12003                                    "IconResolution": "128x128",
12004                                    "IconTheme": "default"
12005                                },
12006                                {
12007                                    "IconUrl": "https://example.test/default-256.ico",
12008                                    "IconFileType": "ico",
12009                                    "IconResolution": "256x256"
12010                                }
12011                            ]
12012                        },
12013                        "Installers": [
12014                            {
12015                                "Architecture": "x64",
12016                                "InstallerType": "exe",
12017                                "InstallerUrl": "https://example.test/Test.Package.exe",
12018                                "InstallerSha256": "ABC123"
12019                            }
12020                        ],
12021                        "ManifestVersion": "1.10.0"
12022                    }
12023                ],
12024                "ManifestVersion": "1.10.0"
12025            }
12026        });
12027        let bytes = serde_json::to_vec(&response).expect("json");
12028
12029        let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
12030
12031        assert_eq!(manifest.icons.len(), 2);
12032        assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
12033        assert_eq!(
12034            document["Icons"][0]["IconUrl"].as_str(),
12035            Some("https://example.test/default-128.png")
12036        );
12037    }
12038
12039    #[test]
12040    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
12041        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
12042        // (Edge, Steam, Discord). pinget must do the same — the filter is
12043        // the only gate that enforces it.
12044        let mut pkg = InstalledPackage {
12045            name: "Edge".to_owned(),
12046            local_id: r"ARP\Machine\X64\Edge".to_owned(),
12047            installed_version: "100.0".to_owned(),
12048            publisher: None,
12049            scope: Some("Machine".to_owned()),
12050            installer_category: Some("exe".to_owned()),
12051            install_location: None,
12052            package_family_names: Vec::new(),
12053            product_codes: Vec::new(),
12054            upgrade_codes: Vec::new(),
12055            correlated: Some(SearchMatch {
12056                source_name: "winget".to_owned(),
12057                source_kind: SourceKind::PreIndexed,
12058                id: "Microsoft.Edge".to_owned(),
12059                name: "Microsoft Edge".to_owned(),
12060                moniker: None,
12061                version: Some("110.0".to_owned()),
12062                channel: None,
12063                match_criteria: None,
12064            }),
12065            winget_package_identifier: None,
12066            winget_source_identifier: None,
12067            installed_version_canonical: false,
12068            correlated_requires_explicit_upgrade: true,
12069            correlated_lacks_compatible_installer: false,
12070        };
12071        let bulk_query = ListQuery {
12072            upgrade_only: true,
12073            ..ListQuery::default()
12074        };
12075        assert!(
12076            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
12077            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
12078        );
12079
12080        // When the user explicitly targets it by id, winget shows it —
12081        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
12082        let filtered_query = ListQuery {
12083            upgrade_only: true,
12084            id: Some("Microsoft.Edge".to_owned()),
12085            ..ListQuery::default()
12086        };
12087        assert!(
12088            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
12089            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
12090        );
12091
12092        // Without the flag, the row appears in bulk upgrade as usual.
12093        pkg.correlated_requires_explicit_upgrade = false;
12094        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12095    }
12096
12097    #[test]
12098    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
12099        let mut pkg = InstalledPackage {
12100            name: "Foo".to_owned(),
12101            local_id: r"ARP\Machine\X64\Foo".to_owned(),
12102            installed_version: "1.0".to_owned(),
12103            publisher: None,
12104            scope: Some("Machine".to_owned()),
12105            installer_category: Some("exe".to_owned()),
12106            install_location: None,
12107            package_family_names: Vec::new(),
12108            product_codes: Vec::new(),
12109            upgrade_codes: Vec::new(),
12110            correlated: Some(SearchMatch {
12111                source_name: "winget".to_owned(),
12112                source_kind: SourceKind::PreIndexed,
12113                id: "Test.Foo".to_owned(),
12114                name: "Foo".to_owned(),
12115                moniker: None,
12116                version: Some("2.0".to_owned()),
12117                channel: None,
12118                match_criteria: None,
12119            }),
12120            winget_package_identifier: None,
12121            winget_source_identifier: None,
12122            installed_version_canonical: false,
12123            correlated_requires_explicit_upgrade: false,
12124            correlated_lacks_compatible_installer: true,
12125        };
12126        let bulk_query = ListQuery {
12127            upgrade_only: true,
12128            ..ListQuery::default()
12129        };
12130        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12131
12132        // Explicit `--id` still surfaces it.
12133        let filtered_query = ListQuery {
12134            upgrade_only: true,
12135            id: Some("Test.Foo".to_owned()),
12136            ..ListQuery::default()
12137        };
12138        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
12139
12140        // Cleared flag → visible in bulk.
12141        pkg.correlated_lacks_compatible_installer = false;
12142        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12143    }
12144
12145    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
12146        let installers = arches
12147            .iter()
12148            .map(|a| Installer {
12149                architecture: Some((*a).to_owned()),
12150                installer_type: None,
12151                url: None,
12152                sha256: None,
12153                product_code: None,
12154                locale: None,
12155                scope: None,
12156                release_date: None,
12157                package_family_name: None,
12158                upgrade_code: None,
12159                platforms: Vec::new(),
12160                minimum_os_version: None,
12161                switches: InstallerSwitches::default(),
12162                commands: Vec::new(),
12163                package_dependencies: Vec::new(),
12164                require_explicit_upgrade: false,
12165                nested_installer_type: None,
12166                nested_installer_files: Vec::new(),
12167            })
12168            .collect();
12169        Manifest {
12170            id: "Test".to_owned(),
12171            name: "Test".to_owned(),
12172            version: "1.0".to_owned(),
12173            channel: String::new(),
12174            publisher: None,
12175            description: None,
12176            moniker: None,
12177            package_url: None,
12178            publisher_url: None,
12179            publisher_support_url: None,
12180            license: None,
12181            license_url: None,
12182            privacy_url: None,
12183            author: None,
12184            copyright: None,
12185            copyright_url: None,
12186            release_notes: None,
12187            release_notes_url: None,
12188            tags: Vec::new(),
12189            agreements: Vec::new(),
12190            package_dependencies: Vec::new(),
12191            documentation: Vec::new(),
12192            icons: Vec::new(),
12193            installers,
12194            require_explicit_upgrade: false,
12195        }
12196    }
12197
12198    #[test]
12199    fn manifest_compatible_installer_neutral_always_matches() {
12200        // `neutral` is in every host's allowed list.
12201        assert!(manifest_has_compatible_installer(
12202            &synthetic_manifest_with_installer_arches(&["neutral"])
12203        ));
12204    }
12205
12206    #[test]
12207    fn manifest_compatible_installer_empty_installers_pass_through() {
12208        // Manifest with no installers (e.g. a docs-only entry): nothing to
12209        // filter against, don't hide the row.
12210        assert!(manifest_has_compatible_installer(
12211            &synthetic_manifest_with_installer_arches(&[])
12212        ));
12213    }
12214
12215    #[test]
12216    fn manifest_compatible_installer_rejects_alien_arch_only() {
12217        // `ppc` is in no host's allowed list — manifest has nothing runnable.
12218        assert!(!manifest_has_compatible_installer(
12219            &synthetic_manifest_with_installer_arches(&["ppc"])
12220        ));
12221    }
12222
12223    #[test]
12224    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
12225        // Mixed list where at least `neutral` is in every host's preference.
12226        assert!(manifest_has_compatible_installer(
12227            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
12228        ));
12229    }
12230
12231    #[test]
12232    fn lookup_unique_normalized_identity_returns_unique_match() {
12233        // Single (norm_name, norm_publisher) intersection — happy path.
12234        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12235        execute_batch_sql(
12236            &connection,
12237            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12238             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12239             INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
12240             INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
12241        )
12242        .expect("seed schema");
12243
12244        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
12245            .expect("query")
12246            .expect("unique match");
12247        assert_eq!(rowid, 100);
12248    }
12249
12250    #[test]
12251    fn lookup_installed_db_identity_match_returns_product_code_match() {
12252        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12253        execute_batch_sql(
12254            &connection,
12255            "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\
12256             CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\
12257             CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\
12258             CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\
12259             CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\
12260             INSERT INTO ids VALUES (1, 'Git.Git');\n\
12261             INSERT INTO names VALUES (1, 'Git');\n\
12262             INSERT INTO manifest VALUES (10, 1, 1);\n\
12263             INSERT INTO productcodes VALUES (20, 'git_is1');\n\
12264             INSERT INTO productcodes_map VALUES (10, 20);",
12265        )
12266        .expect("seed schema");
12267        let package = InstalledPackage {
12268            name: "Git".to_owned(),
12269            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
12270            installed_version: "2.54.0".to_owned(),
12271            publisher: None,
12272            scope: None,
12273            installer_category: None,
12274            install_location: None,
12275            package_family_names: Vec::new(),
12276            product_codes: vec!["git_is1".to_owned()],
12277            upgrade_codes: Vec::new(),
12278            correlated: None,
12279            winget_package_identifier: None,
12280            winget_source_identifier: None,
12281            installed_version_canonical: false,
12282            correlated_requires_explicit_upgrade: false,
12283            correlated_lacks_compatible_installer: false,
12284        };
12285
12286        let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package)
12287            .expect("query")
12288            .expect("product code match");
12289
12290        assert_eq!(id, "Git.Git");
12291        assert_eq!(name, "Git");
12292        assert_eq!(matched_by, "ProductCode");
12293    }
12294
12295    #[test]
12296    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
12297        // Two distinct packages share the same (norm_name, norm_publisher)
12298        // — the Git case where both `Git.Git` and a hypothetical sibling
12299        // normalize identically. winget refuses to correlate when it can't
12300        // disambiguate; pinget must do the same.
12301        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12302        execute_batch_sql(
12303            &connection,
12304            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12305             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12306             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12307             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
12308        )
12309        .expect("seed schema");
12310
12311        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12312        assert!(rowid.is_none(), "ambiguous match must not correlate");
12313    }
12314
12315    #[test]
12316    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
12317        // norm_name has multiple matches but only one shares its
12318        // norm_publisher with the installed package. Winget's intersect
12319        // logic still picks the right one — verify.
12320        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12321        execute_batch_sql(
12322            &connection,
12323            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12324             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12325             INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12326             INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
12327        )
12328        .expect("seed schema");
12329
12330        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12331        assert_eq!(rowid, Some(100));
12332    }
12333
12334    #[test]
12335    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
12336        // Name matches but no publisher row for that package id —
12337        // intersection is empty so we don't correlate.
12338        let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12339        execute_batch_sql(
12340            &connection,
12341            "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12342             CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12343             INSERT INTO norm_names2 VALUES ('foo', 100);\n\
12344             INSERT INTO norm_publishers2 VALUES ('bar', 200);",
12345        )
12346        .expect("seed schema");
12347
12348        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
12349        assert!(
12350            rowid.is_none(),
12351            "name matches package 100 but publisher matches 200 — no intersection"
12352        );
12353    }
12354
12355    #[test]
12356    fn parse_yaml_manifest_bundle_returns_singleton_document() {
12357        let yaml = r#"
12358PackageIdentifier: Test.Package
12359PackageVersion: 1.2.3
12360DefaultLocale: en-US
12361ManifestType: version
12362ManifestVersion: 1.10.0
12363---
12364PackageIdentifier: Test.Package
12365PackageVersion: 1.2.3
12366PackageLocale: en-US
12367PackageName: Test Package
12368Publisher: Example
12369License: MIT
12370ShortDescription: Structured output
12371ManifestType: defaultLocale
12372ManifestVersion: 1.10.0
12373---
12374PackageIdentifier: Test.Package
12375PackageVersion: 1.2.3
12376ManifestType: installer
12377ManifestVersion: 1.10.0
12378Installers:
12379  - Architecture: x64
12380    InstallerType: exe
12381    InstallerUrl: https://example.test/Test.Package.exe
12382    InstallerSha256: ABC123
12383"#;
12384
12385        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
12386
12387        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
12388        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
12389        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
12390    }
12391
12392    #[test]
12393    fn collapse_structured_documents_returns_plural_show_documents() {
12394        let documents = collapse_structured_documents(&[
12395            JsonValue::Array(vec![
12396                serde_json::json!({
12397                    "PackageIdentifier": "Test.Package.One",
12398                    "PackageVersion": "1.0.0",
12399                    "DefaultLocale": "en-US",
12400                    "ManifestType": "version",
12401                    "ManifestVersion": "1.10.0"
12402                }),
12403                serde_json::json!({
12404                    "PackageIdentifier": "Test.Package.One",
12405                    "PackageVersion": "1.0.0",
12406                    "PackageLocale": "en-US",
12407                    "PackageName": "Test Package One",
12408                    "ManifestType": "defaultLocale",
12409                    "ManifestVersion": "1.10.0"
12410                }),
12411                serde_json::json!({
12412                    "PackageIdentifier": "Test.Package.One",
12413                    "PackageVersion": "1.0.0",
12414                    "ManifestType": "installer",
12415                    "ManifestVersion": "1.10.0",
12416                    "Installers": [
12417                        {
12418                            "Architecture": "x64",
12419                            "InstallerType": "exe",
12420                            "InstallerUrl": "https://example.test/one.exe",
12421                            "InstallerSha256": "ABC123"
12422                        }
12423                    ]
12424                }),
12425            ]),
12426            serde_json::json!({
12427                "PackageIdentifier": "Test.Package.Two",
12428                "PackageVersion": "2.0.0",
12429                "PackageLocale": "en-US",
12430                "PackageName": "Test Package Two",
12431                "ManifestType": "singleton",
12432                "ManifestVersion": "1.12.0"
12433            }),
12434        ]);
12435
12436        assert_eq!(documents.len(), 2);
12437        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
12438        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
12439        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
12440        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
12441        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
12442    }
12443
12444    #[test]
12445    fn unsupported_action_result_marks_noop_and_warning() {
12446        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
12447
12448        assert!(result.success);
12449        assert!(result.no_op);
12450        assert_eq!(result.exit_code, 0);
12451        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
12452    }
12453
12454    #[test]
12455    fn create_install_no_op_result_honors_no_upgrade() {
12456        let mut request = InstallRequest::new(PackageQuery {
12457            id: Some("Contoso.App".to_owned()),
12458            ..PackageQuery::default()
12459        });
12460        request.no_upgrade = true;
12461
12462        let manifest = Manifest {
12463            id: "Contoso.App".to_owned(),
12464            name: "Contoso App".to_owned(),
12465            version: "2.0.0".to_owned(),
12466            channel: String::new(),
12467            publisher: None,
12468            description: None,
12469            moniker: None,
12470            package_url: None,
12471            publisher_url: None,
12472            publisher_support_url: None,
12473            license: None,
12474            license_url: None,
12475            privacy_url: None,
12476            author: None,
12477            copyright: None,
12478            copyright_url: None,
12479            release_notes: None,
12480            release_notes_url: None,
12481            tags: Vec::new(),
12482            agreements: Vec::new(),
12483            package_dependencies: Vec::new(),
12484            documentation: Vec::new(),
12485            icons: Vec::new(),
12486            installers: Vec::new(),
12487            require_explicit_upgrade: false,
12488        };
12489        let existing = ListMatch {
12490            name: "Contoso App".to_owned(),
12491            id: "Contoso.App".to_owned(),
12492            local_id: "Contoso.App".to_owned(),
12493            installed_version: "1.0.0".to_owned(),
12494            available_version: Some("2.0.0".to_owned()),
12495            source_name: Some("winget".to_owned()),
12496            publisher: None,
12497            scope: None,
12498            installer_category: None,
12499            install_location: None,
12500            package_family_names: Vec::new(),
12501            product_codes: Vec::new(),
12502            upgrade_codes: Vec::new(),
12503        };
12504
12505        let result =
12506            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12507
12508        assert!(result.success);
12509        assert!(result.no_op);
12510        assert_eq!(result.version, "1.0.0");
12511        assert_eq!(
12512            result.warnings,
12513            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
12514        );
12515    }
12516
12517    #[test]
12518    fn create_install_no_op_result_skips_reinstall_when_current() {
12519        let request = InstallRequest::new(PackageQuery {
12520            id: Some("Contoso.App".to_owned()),
12521            ..PackageQuery::default()
12522        });
12523
12524        let manifest = Manifest {
12525            id: "Contoso.App".to_owned(),
12526            name: "Contoso App".to_owned(),
12527            version: "2.0.0".to_owned(),
12528            channel: String::new(),
12529            publisher: None,
12530            description: None,
12531            moniker: None,
12532            package_url: None,
12533            publisher_url: None,
12534            publisher_support_url: None,
12535            license: None,
12536            license_url: None,
12537            privacy_url: None,
12538            author: None,
12539            copyright: None,
12540            copyright_url: None,
12541            release_notes: None,
12542            release_notes_url: None,
12543            tags: Vec::new(),
12544            agreements: Vec::new(),
12545            package_dependencies: Vec::new(),
12546            documentation: Vec::new(),
12547            icons: Vec::new(),
12548            installers: Vec::new(),
12549            require_explicit_upgrade: false,
12550        };
12551        let existing = ListMatch {
12552            name: "Contoso App".to_owned(),
12553            id: "Contoso.App".to_owned(),
12554            local_id: "Contoso.App".to_owned(),
12555            installed_version: "2.0.0".to_owned(),
12556            available_version: Some("2.0.0".to_owned()),
12557            source_name: Some("winget".to_owned()),
12558            publisher: None,
12559            scope: None,
12560            installer_category: None,
12561            install_location: None,
12562            package_family_names: Vec::new(),
12563            product_codes: Vec::new(),
12564            upgrade_codes: Vec::new(),
12565        };
12566
12567        let result =
12568            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12569
12570        assert!(result.success);
12571        assert!(result.no_op);
12572        assert_eq!(result.version, "2.0.0");
12573        assert_eq!(
12574            result.warnings,
12575            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
12576        );
12577    }
12578
12579    #[test]
12580    fn collapse_structured_document_projects_merged_manifest() {
12581        let document = collapse_structured_document(&serde_json::json!({
12582            "PackageIdentifier": "Test.Package",
12583            "PackageVersion": "1.2.3",
12584            "PackageLocale": "en-US",
12585            "PackageName": "Test Package",
12586            "Publisher": "Example",
12587            "InstallerType": "exe",
12588            "Installers": [
12589                {
12590                    "Architecture": "x64",
12591                    "InstallerUrl": "https://example.test/Test.Package.exe",
12592                    "InstallerSha256": "ABC123"
12593                }
12594            ],
12595            "ManifestType": "merged",
12596            "ManifestVersion": "1.10.0"
12597        }));
12598
12599        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
12600        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
12601        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
12602        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
12603        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
12604    }
12605
12606    #[test]
12607    fn parses_fixture_manifest() {
12608        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
12609            .join("tests")
12610            .join("fixtures")
12611            .join("ManifestV1_28-Singleton.yaml");
12612        let bytes = fs::read(root).expect("fixture bytes");
12613        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
12614        assert_eq!(manifest.id, "microsoft.msixsdk");
12615        assert_eq!(manifest.version, "1.7.32");
12616        assert_eq!(manifest.name, "MSIX SDK");
12617        assert!(!manifest.installers.is_empty());
12618    }
12619
12620    #[test]
12621    fn parses_installer_switches_from_yaml_manifest() {
12622        let yaml = r#"
12623PackageIdentifier: Test.Package
12624PackageVersion: 1.2.3
12625PackageName: Test Package
12626InstallerSwitches:
12627  SilentWithProgress: /SILENT
12628Installers:
12629  - Architecture: x64
12630    InstallerType: inno
12631    InstallerUrl: https://example.test/Test.Package.exe
12632    InstallerSha256: ABC123
12633    InstallerSwitches:
12634      Silent: /VERYSILENT
12635      Interactive: /HELP
12636"#;
12637
12638        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12639        let installer = &manifest.installers[0];
12640
12641        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
12642        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
12643        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
12644    }
12645
12646    #[test]
12647    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
12648        let yaml = r#"
12649PackageIdentifier: Test.Package
12650PackageVersion: 1.2.3
12651PackageName: Test Package
12652Platform:
12653  - Windows.Desktop
12654MinimumOSVersion: 10.0.19041.0
12655Installers:
12656  - Architecture: x64
12657    InstallerType: exe
12658    InstallerUrl: https://example.test/Test.Package.exe
12659    InstallerSha256: ABC123
12660  - Architecture: x64
12661    InstallerType: msix
12662    Platform:
12663      - Windows.Universal
12664    MinimumOSVersion: 10.0.22621.0
12665    InstallerUrl: https://example.test/Test.Package.msix
12666    InstallerSha256: DEF456
12667"#;
12668
12669        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12670
12671        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
12672        assert_eq!(
12673            manifest.installers[0].minimum_os_version.as_deref(),
12674            Some("10.0.19041.0")
12675        );
12676        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
12677        assert_eq!(
12678            manifest.installers[1].minimum_os_version.as_deref(),
12679            Some("10.0.22621.0")
12680        );
12681    }
12682
12683    #[test]
12684    fn installer_switch_arguments_prefer_manifest_switches() {
12685        let installer = Installer {
12686            architecture: None,
12687            installer_type: Some("inno".to_owned()),
12688            url: None,
12689            sha256: None,
12690            product_code: None,
12691            locale: None,
12692            scope: None,
12693            release_date: None,
12694            package_family_name: None,
12695            upgrade_code: None,
12696            platforms: Vec::new(),
12697            minimum_os_version: None,
12698            switches: InstallerSwitches {
12699                silent: Some("/mysilent".to_owned()),
12700                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
12701                interactive: Some("/myinteractive".to_owned()),
12702                ..InstallerSwitches::default()
12703            },
12704            commands: Vec::new(),
12705            package_dependencies: Vec::new(),
12706            require_explicit_upgrade: false,
12707            nested_installer_type: None,
12708            nested_installer_files: Vec::new(),
12709        };
12710        let manifest = Manifest {
12711            id: "Test.Package".to_owned(),
12712            name: "Test Package".to_owned(),
12713            version: "1.0.0".to_owned(),
12714            channel: String::new(),
12715            publisher: None,
12716            description: None,
12717            moniker: None,
12718            package_url: None,
12719            publisher_url: None,
12720            publisher_support_url: None,
12721            license: None,
12722            license_url: None,
12723            privacy_url: None,
12724            author: None,
12725            copyright: None,
12726            copyright_url: None,
12727            release_notes: None,
12728            release_notes_url: None,
12729            tags: Vec::new(),
12730            agreements: Vec::new(),
12731            package_dependencies: Vec::new(),
12732            documentation: Vec::new(),
12733            icons: Vec::new(),
12734            installers: Vec::new(),
12735            require_explicit_upgrade: false,
12736        };
12737
12738        let mut silent_request = InstallRequest::new(PackageQuery::default());
12739        silent_request.mode = InstallerMode::Silent;
12740        let mut progress_request = InstallRequest::new(PackageQuery::default());
12741        progress_request.mode = InstallerMode::SilentWithProgress;
12742        let mut interactive_request = InstallRequest::new(PackageQuery::default());
12743        interactive_request.mode = InstallerMode::Interactive;
12744        assert_eq!(
12745            installer_command_arguments(
12746                "inno",
12747                &silent_request,
12748                &manifest,
12749                Path::new("installer.exe"),
12750                &installer
12751            ),
12752            vec!["/mysilent".to_owned()]
12753        );
12754        assert_eq!(
12755            installer_command_arguments(
12756                "inno",
12757                &progress_request,
12758                &manifest,
12759                Path::new("installer.exe"),
12760                &installer
12761            ),
12762            vec!["/mysilentwithprogress".to_owned()]
12763        );
12764        assert_eq!(
12765            installer_command_arguments(
12766                "inno",
12767                &interactive_request,
12768                &manifest,
12769                Path::new("installer.exe"),
12770                &installer
12771            ),
12772            vec!["/myinteractive".to_owned()]
12773        );
12774    }
12775
12776    #[test]
12777    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
12778        let installer = Installer {
12779            architecture: None,
12780            installer_type: Some("inno".to_owned()),
12781            url: None,
12782            sha256: None,
12783            product_code: None,
12784            locale: None,
12785            scope: None,
12786            release_date: None,
12787            package_family_name: None,
12788            upgrade_code: None,
12789            platforms: Vec::new(),
12790            minimum_os_version: None,
12791            switches: InstallerSwitches::default(),
12792            commands: Vec::new(),
12793            package_dependencies: Vec::new(),
12794            require_explicit_upgrade: false,
12795            nested_installer_type: None,
12796            nested_installer_files: Vec::new(),
12797        };
12798        let manifest = Manifest {
12799            id: "Test.Package".to_owned(),
12800            name: "Test Package".to_owned(),
12801            version: "1.0.0".to_owned(),
12802            channel: String::new(),
12803            publisher: None,
12804            description: None,
12805            moniker: None,
12806            package_url: None,
12807            publisher_url: None,
12808            publisher_support_url: None,
12809            license: None,
12810            license_url: None,
12811            privacy_url: None,
12812            author: None,
12813            copyright: None,
12814            copyright_url: None,
12815            release_notes: None,
12816            release_notes_url: None,
12817            tags: Vec::new(),
12818            agreements: Vec::new(),
12819            package_dependencies: Vec::new(),
12820            documentation: Vec::new(),
12821            icons: Vec::new(),
12822            installers: Vec::new(),
12823            require_explicit_upgrade: false,
12824        };
12825        let mut progress_request = InstallRequest::new(PackageQuery::default());
12826        progress_request.mode = InstallerMode::SilentWithProgress;
12827        let mut silent_request = InstallRequest::new(PackageQuery::default());
12828        silent_request.mode = InstallerMode::Silent;
12829
12830        assert_eq!(
12831            installer_command_arguments(
12832                "inno",
12833                &progress_request,
12834                &manifest,
12835                Path::new("installer.exe"),
12836                &installer
12837            ),
12838            vec![
12839                "/SP-".to_owned(),
12840                "/SILENT".to_owned(),
12841                "/SUPPRESSMSGBOXES".to_owned(),
12842                "/NORESTART".to_owned()
12843            ]
12844        );
12845        assert_eq!(
12846            installer_command_arguments(
12847                "inno",
12848                &silent_request,
12849                &manifest,
12850                Path::new("installer.exe"),
12851                &installer
12852            ),
12853            vec![
12854                "/SP-".to_owned(),
12855                "/VERYSILENT".to_owned(),
12856                "/SUPPRESSMSGBOXES".to_owned(),
12857                "/NORESTART".to_owned()
12858            ]
12859        );
12860    }
12861
12862    #[test]
12863    fn installer_command_arguments_append_manifest_and_cli_switches() {
12864        let installer = Installer {
12865            architecture: None,
12866            installer_type: Some("msi".to_owned()),
12867            url: None,
12868            sha256: None,
12869            product_code: None,
12870            locale: None,
12871            scope: None,
12872            release_date: None,
12873            package_family_name: None,
12874            upgrade_code: None,
12875            platforms: Vec::new(),
12876            minimum_os_version: None,
12877            switches: InstallerSwitches {
12878                custom: Some("ADDLOCAL=Core".to_owned()),
12879                log: Some("/log \"<LOGPATH>\"".to_owned()),
12880                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
12881                ..InstallerSwitches::default()
12882            },
12883            commands: Vec::new(),
12884            package_dependencies: Vec::new(),
12885            require_explicit_upgrade: false,
12886            nested_installer_type: None,
12887            nested_installer_files: Vec::new(),
12888        };
12889        let manifest = Manifest {
12890            id: "ShareX.ShareX".to_owned(),
12891            name: "ShareX".to_owned(),
12892            version: "19.0.2".to_owned(),
12893            channel: String::new(),
12894            publisher: None,
12895            description: None,
12896            moniker: None,
12897            package_url: None,
12898            publisher_url: None,
12899            publisher_support_url: None,
12900            license: None,
12901            license_url: None,
12902            privacy_url: None,
12903            author: None,
12904            copyright: None,
12905            copyright_url: None,
12906            release_notes: None,
12907            release_notes_url: None,
12908            tags: Vec::new(),
12909            agreements: Vec::new(),
12910            package_dependencies: Vec::new(),
12911            documentation: Vec::new(),
12912            icons: Vec::new(),
12913            installers: Vec::new(),
12914            require_explicit_upgrade: false,
12915        };
12916        let mut request = InstallRequest::new(PackageQuery::default());
12917        request.mode = InstallerMode::Silent;
12918        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
12919        request.custom = Some("REBOOT=ReallySuppress".to_owned());
12920        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
12921
12922        assert_eq!(
12923            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
12924            vec![
12925                "/i".to_owned(),
12926                r"C:\temp\ShareX.msi".to_owned(),
12927                "/quiet".to_owned(),
12928                "/norestart".to_owned(),
12929                "/log".to_owned(),
12930                r"C:\temp\winget.log".to_owned(),
12931                "ADDLOCAL=Core".to_owned(),
12932                "REBOOT=ReallySuppress".to_owned(),
12933                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
12934            ]
12935        );
12936    }
12937
12938    #[test]
12939    fn msi_shell_execute_arguments_include_msiexec_target() {
12940        let installer = Installer {
12941            architecture: None,
12942            installer_type: Some("wix".to_owned()),
12943            url: None,
12944            sha256: None,
12945            product_code: None,
12946            locale: None,
12947            scope: None,
12948            release_date: None,
12949            package_family_name: None,
12950            upgrade_code: None,
12951            platforms: Vec::new(),
12952            minimum_os_version: None,
12953            switches: InstallerSwitches {
12954                log: Some("/log \"<LOGPATH>\"".to_owned()),
12955                install_location: Some("INSTALLDIR=\"<INSTALLPATH>\"".to_owned()),
12956                ..InstallerSwitches::default()
12957            },
12958            commands: Vec::new(),
12959            package_dependencies: Vec::new(),
12960            require_explicit_upgrade: false,
12961            nested_installer_type: None,
12962            nested_installer_files: Vec::new(),
12963        };
12964        let mut request = InstallRequest::new(PackageQuery::default());
12965        request.mode = InstallerMode::Silent;
12966        request.log_path = Some(PathBuf::from(r"C:\temp\node.log"));
12967        request.install_location = Some(r"C:\Program Files\nodejs".to_owned());
12968
12969        assert_eq!(
12970            installer_shell_execute_arguments(
12971                "wix",
12972                &request,
12973                &Manifest {
12974                    id: "OpenJS.NodeJS.22".to_owned(),
12975                    name: "Node.js".to_owned(),
12976                    version: "22.22.3".to_owned(),
12977                    channel: String::new(),
12978                    publisher: None,
12979                    description: None,
12980                    moniker: None,
12981                    package_url: None,
12982                    publisher_url: None,
12983                    publisher_support_url: None,
12984                    license: None,
12985                    license_url: None,
12986                    privacy_url: None,
12987                    author: None,
12988                    copyright: None,
12989                    copyright_url: None,
12990                    release_notes: None,
12991                    release_notes_url: None,
12992                    tags: Vec::new(),
12993                    agreements: Vec::new(),
12994                    package_dependencies: Vec::new(),
12995                    documentation: Vec::new(),
12996                    icons: Vec::new(),
12997                    installers: Vec::new(),
12998                    require_explicit_upgrade: false,
12999                },
13000                Path::new(r"C:\temp\node.msi"),
13001                &installer
13002            ),
13003            vec![
13004                "/i".to_owned(),
13005                r"C:\temp\node.msi".to_owned(),
13006                "/quiet".to_owned(),
13007                "/norestart".to_owned(),
13008                "/log".to_owned(),
13009                r"C:\temp\node.log".to_owned(),
13010                r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13011            ]
13012        );
13013    }
13014
13015    #[test]
13016    fn msi_direct_arguments_omit_msiexec_target() {
13017        let installer = Installer::default();
13018        let mut request = InstallRequest::new(PackageQuery::default());
13019        request.mode = InstallerMode::Silent;
13020
13021        assert_eq!(
13022            installer_direct_msi_arguments(
13023                "wix",
13024                &request,
13025                &Manifest {
13026                    id: "Test.Package".to_owned(),
13027                    name: "Test".to_owned(),
13028                    version: "1.0.0".to_owned(),
13029                    channel: String::new(),
13030                    publisher: None,
13031                    description: None,
13032                    moniker: None,
13033                    package_url: None,
13034                    publisher_url: None,
13035                    publisher_support_url: None,
13036                    license: None,
13037                    license_url: None,
13038                    privacy_url: None,
13039                    author: None,
13040                    copyright: None,
13041                    copyright_url: None,
13042                    release_notes: None,
13043                    release_notes_url: None,
13044                    tags: Vec::new(),
13045                    agreements: Vec::new(),
13046                    package_dependencies: Vec::new(),
13047                    documentation: Vec::new(),
13048                    icons: Vec::new(),
13049                    installers: Vec::new(),
13050                    require_explicit_upgrade: false,
13051                },
13052                Path::new(r"C:\temp\test.msi"),
13053                &installer
13054            ),
13055            vec!["/quiet".to_owned(), "/norestart".to_owned()]
13056        );
13057    }
13058
13059    #[test]
13060    fn msi_direct_arguments_enable_uac_only_for_quiet_ui() {
13061        let args = vec![
13062            "/quiet".to_owned(),
13063            "/norestart".to_owned(),
13064            "/log".to_owned(),
13065            r"C:\temp\node.log".to_owned(),
13066            r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13067        ];
13068
13069        let parsed = parse_msi_direct_arguments(&args).expect("parsed");
13070
13071        assert_eq!(parsed.ui_level, 0x102);
13072        assert_eq!(
13073            parsed.properties,
13074            r#"REBOOT=ReallySuppress INSTALLDIR="C:\Program Files\nodejs""#
13075        );
13076        assert_eq!(parsed.log_path.as_deref(), Some(r"C:\temp\node.log"));
13077    }
13078
13079    #[cfg(windows)]
13080    #[test]
13081    fn machine_scope_silent_msi_uses_shell_execute_instead_of_direct_msi() {
13082        let mut request = InstallRequest::new(PackageQuery::default());
13083        request.mode = InstallerMode::Silent;
13084
13085        let installer = Installer {
13086            installer_type: Some("wix".to_owned()),
13087            scope: Some("machine".to_owned()),
13088            ..Installer::default()
13089        };
13090
13091        assert!(!should_run_msi_direct(&request, &installer));
13092        assert!(should_elevate_msi_shell_execute(&installer));
13093    }
13094
13095    #[test]
13096    fn installer_download_if_range_prefers_strong_etag_and_rejects_weak_etag() {
13097        let strong = InstallerDownloadMetadata {
13098            url: "https://example.test/app.exe".to_owned(),
13099            etag: Some(r#""abc""#.to_owned()),
13100            last_modified: Some("Wed, 03 Jun 2026 21:00:00 GMT".to_owned()),
13101            total_length: Some(100),
13102            offset: 50,
13103        };
13104        let weak = InstallerDownloadMetadata {
13105            etag: Some(r#"W/"abc""#.to_owned()),
13106            ..strong.clone()
13107        };
13108
13109        assert_eq!(installer_download_if_range(&strong).as_deref(), Some(r#""abc""#));
13110        assert_eq!(
13111            installer_download_if_range(&weak).as_deref(),
13112            Some("Wed, 03 Jun 2026 21:00:00 GMT")
13113        );
13114    }
13115
13116    #[test]
13117    fn parse_content_range_validates_resume_start_and_total() {
13118        let value = HeaderValue::from_static("bytes 50-99/100");
13119        let parsed = parse_content_range(Some(&value), 50).expect("content range");
13120
13121        assert_eq!(parsed.total_length, Some(100));
13122        assert!(parse_content_range(Some(&value), 49).is_err());
13123    }
13124
13125    #[test]
13126    fn installer_download_sidecar_paths_append_suffixes() {
13127        let path = Path::new(r"C:\temp\installer.msi");
13128
13129        assert_eq!(sidecar_path(path, "part"), PathBuf::from(r"C:\temp\installer.msi.part"));
13130        assert_eq!(
13131            sidecar_path(path, "part.json"),
13132            PathBuf::from(r"C:\temp\installer.msi.part.json")
13133        );
13134    }
13135
13136    #[test]
13137    fn shell_execute_argument_joining_quotes_spaces() {
13138        assert_eq!(
13139            join_windows_arguments(&[
13140                "/quiet".to_owned(),
13141                r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13142                r#"VALUE=has "quotes""#.to_owned(),
13143            ]),
13144            r#"/quiet "INSTALLDIR=C:\Program Files\nodejs" "VALUE=has \"quotes\"""#
13145        );
13146    }
13147
13148    #[test]
13149    fn decompresses_mszyml_payload() {
13150        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
13151        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13152        encoder.write_all(payload.as_bytes()).expect("write payload");
13153        let compressed = encoder.finish().expect("finish payload");
13154        let mut mszip = b"CK".to_vec();
13155        mszip.extend_from_slice(&compressed);
13156
13157        let decompressed = decompress_mszyml(&mszip).expect("decompress");
13158        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13159        assert_eq!(parsed.versions[0].version, "1.2.3");
13160    }
13161
13162    #[test]
13163    fn decompresses_mszyml_payload_with_prefix() {
13164        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
13165        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13166        encoder.write_all(payload.as_bytes()).expect("write payload");
13167        let compressed = encoder.finish().expect("finish payload");
13168        let mut prefixed = vec![0u8; 28];
13169        prefixed.extend_from_slice(b"CK");
13170        prefixed.extend_from_slice(&compressed);
13171
13172        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
13173        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13174        assert_eq!(parsed.versions[0].version, "1.2.3");
13175    }
13176
13177    #[test]
13178    fn compares_versions_naturally() {
13179        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
13180        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
13181        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
13182        assert_eq!(compare_version("25.4.1", "< 25.4.1"), Ordering::Greater);
13183        assert_eq!(compare_version("< 25.4.1", "25.4.1"), Ordering::Less);
13184    }
13185
13186    #[test]
13187    fn installer_hash_verification_fails_on_mismatch_without_override() {
13188        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
13189        assert!(result.is_err());
13190        assert!(
13191            result
13192                .expect_err("hash mismatch")
13193                .to_string()
13194                .contains("Installer hash mismatch")
13195        );
13196    }
13197
13198    #[test]
13199    fn installer_hash_verification_allows_mismatch_with_override() {
13200        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
13201        verify_installer_hash(None, b"pinget", false).expect("missing hash");
13202    }
13203
13204    #[test]
13205    fn search_query_many_includes_tag_and_command_conditions() {
13206        let query = PackageQuery {
13207            query: Some("terminal".to_owned()),
13208            ..PackageQuery::default()
13209        };
13210
13211        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13212
13213        assert!(where_clause.contains("tags2"));
13214        assert!(where_clause.contains("commands2"));
13215        assert_eq!(params.len(), 5);
13216    }
13217
13218    #[test]
13219    fn tag_and_command_conditions_are_non_correlated() {
13220        // The tag/command map lookups must be emitted as non-correlated
13221        // `rowid IN (SELECT ...)` subqueries. A correlated `EXISTS (... WHERE
13222        // map.owner = packages.rowid ...)` re-scans the un-indexed map tables
13223        // per package row, which is quadratic (~19s on the real winget index
13224        // under SQLite, and an effective hang under Turso). Guard against a
13225        // regression back to the correlated form.
13226        let query = PackageQuery {
13227            query: Some("terminal".to_owned()),
13228            ..PackageQuery::default()
13229        };
13230
13231        let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13232
13233        assert!(where_clause.contains("packages.rowid IN (SELECT"));
13234        assert!(!where_clause.contains("EXISTS"));
13235        assert!(!where_clause.contains("= packages.rowid"));
13236    }
13237
13238    #[test]
13239    fn show_query_single_omits_tag_and_command_conditions() {
13240        let query = PackageQuery {
13241            query: Some("terminal".to_owned()),
13242            ..PackageQuery::default()
13243        };
13244
13245        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
13246
13247        assert!(!where_clause.contains("tags2"));
13248        assert!(!where_clause.contains("commands2"));
13249        assert_eq!(params.len(), 3);
13250    }
13251
13252    #[test]
13253    fn explicit_tag_filter_uses_exact_match() {
13254        let query = PackageQuery {
13255            tag: Some("terminal".to_owned()),
13256            ..PackageQuery::default()
13257        };
13258
13259        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13260
13261        assert!(where_clause.contains("tags2"));
13262        assert_eq!(params, vec!["terminal".to_owned()]);
13263    }
13264
13265    #[test]
13266    fn rest_tag_filter_uses_exact_match() {
13267        let query = PackageQuery {
13268            tag: Some("terminal".to_owned()),
13269            ..PackageQuery::default()
13270        };
13271        let info = RestInformation {
13272            required_package_match_fields: Vec::new(),
13273            unsupported_package_match_fields: Vec::new(),
13274            required_query_parameters: Vec::new(),
13275            ..RestInformation::default()
13276        };
13277
13278        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
13279        let filters = body["Filters"].as_array().expect("filters");
13280
13281        assert_eq!(filters.len(), 1);
13282        assert_eq!(filters[0]["PackageMatchField"], "Tag");
13283        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
13284        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
13285    }
13286
13287    #[test]
13288    fn selects_installer_using_requested_filters() {
13289        let installers = vec![
13290            Installer {
13291                architecture: Some("x86".to_owned()),
13292                installer_type: Some("zip".to_owned()),
13293                url: None,
13294                sha256: None,
13295                product_code: None,
13296                locale: Some("en-US".to_owned()),
13297                scope: Some("user".to_owned()),
13298                release_date: None,
13299                package_family_name: None,
13300                upgrade_code: None,
13301                platforms: Vec::new(),
13302                minimum_os_version: None,
13303                switches: InstallerSwitches::default(),
13304                commands: Vec::new(),
13305                package_dependencies: Vec::new(),
13306                require_explicit_upgrade: false,
13307                nested_installer_type: None,
13308                nested_installer_files: Vec::new(),
13309            },
13310            Installer {
13311                architecture: Some("x64".to_owned()),
13312                installer_type: Some("msix".to_owned()),
13313                url: None,
13314                sha256: None,
13315                product_code: None,
13316                locale: Some("en-US".to_owned()),
13317                scope: Some("user".to_owned()),
13318                release_date: None,
13319                package_family_name: None,
13320                upgrade_code: None,
13321                platforms: Vec::new(),
13322                minimum_os_version: None,
13323                switches: InstallerSwitches::default(),
13324                commands: vec!["demo".to_owned()],
13325                package_dependencies: Vec::new(),
13326                require_explicit_upgrade: false,
13327                nested_installer_type: None,
13328                nested_installer_files: Vec::new(),
13329            },
13330        ];
13331        let query = PackageQuery {
13332            installer_type: Some("msix".to_owned()),
13333            installer_architecture: Some("x64".to_owned()),
13334            install_scope: Some("user".to_owned()),
13335            locale: Some("en-US".to_owned()),
13336            ..PackageQuery::default()
13337        };
13338
13339        let selected = select_installer(&installers, &query).expect("selected installer");
13340        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
13341        assert_eq!(selected.architecture.as_deref(), Some("x64"));
13342    }
13343
13344    #[test]
13345    fn selects_first_installer_when_rank_ties() {
13346        let installers = vec![
13347            Installer {
13348                architecture: Some("x64".to_owned()),
13349                installer_type: Some("exe".to_owned()),
13350                url: None,
13351                sha256: None,
13352                product_code: None,
13353                locale: Some("en-US".to_owned()),
13354                scope: Some("machine".to_owned()),
13355                release_date: None,
13356                package_family_name: None,
13357                upgrade_code: None,
13358                platforms: Vec::new(),
13359                minimum_os_version: None,
13360                switches: InstallerSwitches::default(),
13361                commands: Vec::new(),
13362                package_dependencies: Vec::new(),
13363                require_explicit_upgrade: false,
13364                nested_installer_type: None,
13365                nested_installer_files: Vec::new(),
13366            },
13367            Installer {
13368                architecture: Some("x64".to_owned()),
13369                installer_type: Some("exe".to_owned()),
13370                url: None,
13371                sha256: None,
13372                product_code: None,
13373                locale: Some("en-US".to_owned()),
13374                scope: Some("user".to_owned()),
13375                release_date: None,
13376                package_family_name: None,
13377                upgrade_code: None,
13378                platforms: Vec::new(),
13379                minimum_os_version: None,
13380                switches: InstallerSwitches::default(),
13381                commands: Vec::new(),
13382                package_dependencies: Vec::new(),
13383                require_explicit_upgrade: false,
13384                nested_installer_type: None,
13385                nested_installer_files: Vec::new(),
13386            },
13387        ];
13388
13389        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
13390        assert_eq!(selected.scope.as_deref(), Some("machine"));
13391    }
13392
13393    #[test]
13394    fn selects_installer_using_requested_platform() {
13395        let installers = vec![
13396            Installer {
13397                architecture: Some("x64".to_owned()),
13398                installer_type: Some("exe".to_owned()),
13399                url: None,
13400                sha256: None,
13401                product_code: None,
13402                locale: Some("en-US".to_owned()),
13403                scope: Some("user".to_owned()),
13404                release_date: None,
13405                package_family_name: None,
13406                upgrade_code: None,
13407                platforms: vec!["Windows.Universal".to_owned()],
13408                minimum_os_version: None,
13409                switches: InstallerSwitches::default(),
13410                commands: Vec::new(),
13411                package_dependencies: Vec::new(),
13412                require_explicit_upgrade: false,
13413                nested_installer_type: None,
13414                nested_installer_files: Vec::new(),
13415            },
13416            Installer {
13417                architecture: Some("x64".to_owned()),
13418                installer_type: Some("exe".to_owned()),
13419                url: None,
13420                sha256: None,
13421                product_code: None,
13422                locale: Some("en-US".to_owned()),
13423                scope: Some("user".to_owned()),
13424                release_date: None,
13425                package_family_name: None,
13426                upgrade_code: None,
13427                platforms: vec!["Windows.Desktop".to_owned()],
13428                minimum_os_version: None,
13429                switches: InstallerSwitches::default(),
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        ];
13437        let query = PackageQuery {
13438            installer_type: Some("exe".to_owned()),
13439            platform: Some("Windows.Desktop".to_owned()),
13440            ..PackageQuery::default()
13441        };
13442
13443        let selected = select_installer(&installers, &query).expect("selected installer");
13444        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
13445    }
13446
13447    #[test]
13448    fn selects_installer_using_requested_os_version() {
13449        let installers = vec![
13450            Installer {
13451                architecture: Some("x64".to_owned()),
13452                installer_type: Some("exe".to_owned()),
13453                url: None,
13454                sha256: None,
13455                product_code: None,
13456                locale: Some("en-US".to_owned()),
13457                scope: Some("user".to_owned()),
13458                release_date: None,
13459                package_family_name: None,
13460                upgrade_code: None,
13461                platforms: Vec::new(),
13462                minimum_os_version: Some("10.0.22621.0".to_owned()),
13463                switches: InstallerSwitches::default(),
13464                commands: Vec::new(),
13465                package_dependencies: Vec::new(),
13466                require_explicit_upgrade: false,
13467                nested_installer_type: None,
13468                nested_installer_files: Vec::new(),
13469            },
13470            Installer {
13471                architecture: Some("x64".to_owned()),
13472                installer_type: Some("exe".to_owned()),
13473                url: None,
13474                sha256: None,
13475                product_code: None,
13476                locale: Some("en-US".to_owned()),
13477                scope: Some("user".to_owned()),
13478                release_date: None,
13479                package_family_name: None,
13480                upgrade_code: None,
13481                platforms: Vec::new(),
13482                minimum_os_version: Some("10.0.19041.0".to_owned()),
13483                switches: InstallerSwitches::default(),
13484                commands: Vec::new(),
13485                package_dependencies: Vec::new(),
13486                require_explicit_upgrade: false,
13487                nested_installer_type: None,
13488                nested_installer_files: Vec::new(),
13489            },
13490        ];
13491        let query = PackageQuery {
13492            installer_type: Some("exe".to_owned()),
13493            os_version: Some("10.0.19045.0".to_owned()),
13494            ..PackageQuery::default()
13495        };
13496
13497        let selected = select_installer(&installers, &query).expect("selected installer");
13498        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
13499    }
13500
13501    #[test]
13502    fn derives_correlation_name_candidates() {
13503        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
13504        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
13505        assert!(candidates.contains(&"PowerToys".to_owned()));
13506    }
13507
13508    #[test]
13509    fn correlates_installed_package_to_available_match() {
13510        let installed = InstalledPackage {
13511            name: "PowerToys (Preview) x64".to_owned(),
13512            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13513            installed_version: "0.98.1".to_owned(),
13514            publisher: None,
13515            scope: Some("Machine".to_owned()),
13516            installer_category: Some("exe".to_owned()),
13517            install_location: None,
13518            package_family_names: Vec::new(),
13519            product_codes: Vec::new(),
13520            upgrade_codes: Vec::new(),
13521            correlated: None,
13522            winget_package_identifier: None,
13523            winget_source_identifier: None,
13524            installed_version_canonical: false,
13525            correlated_requires_explicit_upgrade: false,
13526            correlated_lacks_compatible_installer: false,
13527        };
13528        let candidates = vec![SearchMatch {
13529            source_name: "winget".to_owned(),
13530            source_kind: SourceKind::PreIndexed,
13531            id: "Microsoft.PowerToys".to_owned(),
13532            name: "PowerToys".to_owned(),
13533            moniker: None,
13534            version: Some("0.98.1".to_owned()),
13535            channel: None,
13536            match_criteria: None,
13537        }];
13538
13539        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13540        assert_eq!(correlated.id, "Microsoft.PowerToys");
13541    }
13542
13543    #[test]
13544    fn list_query_uses_available_lookup_for_tag_filters() {
13545        let query = ListQuery {
13546            tag: Some("terminal".to_owned()),
13547            ..ListQuery::default()
13548        };
13549
13550        assert!(list_query_needs_available_lookup(&query));
13551    }
13552
13553    #[test]
13554    fn list_tag_filter_requires_correlated_match() {
13555        let package = InstalledPackage {
13556            name: "PowerToys (Preview) x64".to_owned(),
13557            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13558            installed_version: "0.98.1".to_owned(),
13559            publisher: None,
13560            scope: Some("Machine".to_owned()),
13561            installer_category: Some("exe".to_owned()),
13562            install_location: None,
13563            package_family_names: Vec::new(),
13564            product_codes: Vec::new(),
13565            upgrade_codes: Vec::new(),
13566            correlated: None,
13567            winget_package_identifier: None,
13568            winget_source_identifier: None,
13569            installed_version_canonical: false,
13570            correlated_requires_explicit_upgrade: false,
13571            correlated_lacks_compatible_installer: false,
13572        };
13573        let query = ListQuery {
13574            tag: Some("powertoys".to_owned()),
13575            ..ListQuery::default()
13576        };
13577
13578        assert!(!list_package_matches(&package, &query));
13579    }
13580
13581    #[test]
13582    fn list_lookup_ignores_display_count() {
13583        let query = ListQuery {
13584            query: Some("git".to_owned()),
13585            count: Some(5),
13586            ..ListQuery::default()
13587        };
13588
13589        let package_query = package_query_from_list_query(&query);
13590        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
13591    }
13592
13593    #[test]
13594    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
13595        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
13596        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
13597        // whichever candidate happened to come last in iteration order. The installed
13598        // display name's prefix decides the correct one.
13599        let installed = InstalledPackage {
13600            name: "Notepad++ (ARM 64-bit)".to_owned(),
13601            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
13602            installed_version: "8.8.9".to_owned(),
13603            publisher: None,
13604            scope: Some("Machine".to_owned()),
13605            installer_category: Some("exe".to_owned()),
13606            install_location: None,
13607            package_family_names: Vec::new(),
13608            product_codes: Vec::new(),
13609            upgrade_codes: Vec::new(),
13610            correlated: None,
13611            winget_package_identifier: None,
13612            winget_source_identifier: None,
13613            installed_version_canonical: false,
13614            correlated_requires_explicit_upgrade: false,
13615            correlated_lacks_compatible_installer: false,
13616        };
13617        let candidates = vec![
13618            SearchMatch {
13619                source_name: "winget".to_owned(),
13620                source_kind: SourceKind::PreIndexed,
13621                id: "ndd.Notepad--".to_owned(),
13622                name: "Notepad--".to_owned(),
13623                moniker: None,
13624                version: Some("1.0.0".to_owned()),
13625                channel: None,
13626                match_criteria: None,
13627            },
13628            SearchMatch {
13629                source_name: "winget".to_owned(),
13630                source_kind: SourceKind::PreIndexed,
13631                id: "Notepad++.Notepad++".to_owned(),
13632                name: "Notepad++".to_owned(),
13633                moniker: None,
13634                version: Some("8.9.5".to_owned()),
13635                channel: None,
13636                match_criteria: None,
13637            },
13638        ];
13639
13640        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13641        assert_eq!(correlated.id, "Notepad++.Notepad++");
13642    }
13643
13644    #[test]
13645    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
13646        // Without the prefix bonus, the loose substring match picked up `Studio`
13647        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
13648        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
13649        // sort order favored the alphabetically-later candidate.
13650        let installed = InstalledPackage {
13651            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
13652            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
13653            installed_version: "40.10.18029".to_owned(),
13654            publisher: None,
13655            scope: Some("Machine".to_owned()),
13656            installer_category: Some("msi".to_owned()),
13657            install_location: None,
13658            package_family_names: Vec::new(),
13659            product_codes: Vec::new(),
13660            upgrade_codes: Vec::new(),
13661            correlated: None,
13662            winget_package_identifier: None,
13663            winget_source_identifier: None,
13664            installed_version_canonical: false,
13665            correlated_requires_explicit_upgrade: false,
13666            correlated_lacks_compatible_installer: false,
13667        };
13668        let candidates = vec![
13669            SearchMatch {
13670                source_name: "winget".to_owned(),
13671                source_kind: SourceKind::PreIndexed,
13672                id: "ZeroBrane.Studio".to_owned(),
13673                name: "Studio".to_owned(),
13674                moniker: None,
13675                version: Some("1.0".to_owned()),
13676                channel: None,
13677                match_criteria: None,
13678            },
13679            SearchMatch {
13680                source_name: "winget".to_owned(),
13681                source_kind: SourceKind::PreIndexed,
13682                id: "BrickLink.Studio".to_owned(),
13683                name: "Studio".to_owned(),
13684                moniker: None,
13685                version: Some("1.0".to_owned()),
13686                channel: None,
13687                match_criteria: None,
13688            },
13689            SearchMatch {
13690                source_name: "winget".to_owned(),
13691                source_kind: SourceKind::PreIndexed,
13692                id: "Microsoft.DotNet.SDK.10".to_owned(),
13693                name: "Microsoft .NET SDK 10.0".to_owned(),
13694                moniker: None,
13695                version: Some("10.0.204".to_owned()),
13696                channel: None,
13697                match_criteria: None,
13698            },
13699        ];
13700
13701        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13702        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
13703    }
13704
13705    #[test]
13706    fn msix_packages_do_not_correlate_via_name() {
13707        // MSIX correlation must go through the v2 index's `pfns2` table — name
13708        // fallback is wrong because two MSIX packages can legitimately share a
13709        // display name without sharing identity (`Microsoft Edge Stable` MSIX
13710        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
13711        // the catalog Inno installer). Even when the names match exactly the
13712        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
13713        // PFN hit; that lookup happens in `correlate_installed_via_index`.
13714        let installed = InstalledPackage {
13715            name: "Microsoft Teams".to_owned(),
13716            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13717            installed_version: "25290.205.4069.4894".to_owned(),
13718            publisher: None,
13719            scope: Some("User".to_owned()),
13720            installer_category: Some("msix".to_owned()),
13721            install_location: None,
13722            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13723            product_codes: Vec::new(),
13724            upgrade_codes: Vec::new(),
13725            correlated: None,
13726            winget_package_identifier: None,
13727            winget_source_identifier: None,
13728            installed_version_canonical: false,
13729            correlated_requires_explicit_upgrade: false,
13730            correlated_lacks_compatible_installer: false,
13731        };
13732        let candidates = vec![SearchMatch {
13733            source_name: "winget".to_owned(),
13734            source_kind: SourceKind::PreIndexed,
13735            id: "Microsoft.Teams".to_owned(),
13736            name: "Microsoft Teams".to_owned(),
13737            moniker: None,
13738            version: Some("26106.1906.4665.7308".to_owned()),
13739            channel: None,
13740            match_criteria: None,
13741        }];
13742
13743        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13744    }
13745
13746    #[test]
13747    fn name_fallback_refuses_ambiguous_winners() {
13748        // The user has Git installed (publisher "The Git Development Community"
13749        // — but publisher isn't in the scoring function). Two catalog packages
13750        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
13751        // publisher disambiguation they score identically; winget refuses to
13752        // correlate (the install lists with empty Source). pinget must do the
13753        // same to avoid manufacturing an upgrade against the wrong catalog
13754        // package.
13755        let installed = InstalledPackage {
13756            name: "Git".to_owned(),
13757            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
13758            installed_version: "2.53.0".to_owned(),
13759            publisher: Some("The Git Development Community".to_owned()),
13760            scope: Some("Machine".to_owned()),
13761            installer_category: Some("exe".to_owned()),
13762            install_location: None,
13763            package_family_names: Vec::new(),
13764            product_codes: Vec::new(),
13765            upgrade_codes: Vec::new(),
13766            correlated: None,
13767            winget_package_identifier: None,
13768            winget_source_identifier: None,
13769            installed_version_canonical: false,
13770            correlated_requires_explicit_upgrade: false,
13771            correlated_lacks_compatible_installer: false,
13772        };
13773        let candidates = vec![
13774            SearchMatch {
13775                source_name: "winget".to_owned(),
13776                source_kind: SourceKind::PreIndexed,
13777                id: "Git.Git".to_owned(),
13778                name: "Git".to_owned(),
13779                moniker: None,
13780                version: Some("2.54.0".to_owned()),
13781                channel: None,
13782                match_criteria: None,
13783            },
13784            SearchMatch {
13785                source_name: "winget".to_owned(),
13786                source_kind: SourceKind::PreIndexed,
13787                id: "Microsoft.Git".to_owned(),
13788                name: "Git".to_owned(),
13789                moniker: None,
13790                version: Some("2.53.0.0.7".to_owned()),
13791                channel: None,
13792                match_criteria: None,
13793            },
13794        ];
13795
13796        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13797    }
13798
13799    #[test]
13800    fn msix_with_resource_string_name_does_not_correlate() {
13801        // Some MSIX entries have unresolved resource-string display names
13802        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
13803        // unrelated catalog package via the loose substring rule.
13804        let installed = InstalledPackage {
13805            name: "ms-resource:appDisplayName".to_owned(),
13806            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
13807            installed_version: "1.28.239.0".to_owned(),
13808            publisher: None,
13809            scope: Some("User".to_owned()),
13810            installer_category: Some("msix".to_owned()),
13811            install_location: None,
13812            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
13813            product_codes: Vec::new(),
13814            upgrade_codes: Vec::new(),
13815            correlated: None,
13816            winget_package_identifier: None,
13817            winget_source_identifier: None,
13818            installed_version_canonical: false,
13819            correlated_requires_explicit_upgrade: false,
13820            correlated_lacks_compatible_installer: false,
13821        };
13822        let candidates = vec![SearchMatch {
13823            source_name: "winget".to_owned(),
13824            source_kind: SourceKind::PreIndexed,
13825            id: "Microsoft.AppInstaller".to_owned(),
13826            name: "App Installer".to_owned(),
13827            moniker: None,
13828            version: Some("1.28.240.0".to_owned()),
13829            channel: None,
13830            match_criteria: None,
13831        }];
13832
13833        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13834    }
13835
13836    #[test]
13837    fn strict_list_correlation_avoids_short_substring_matches() {
13838        let installed = InstalledPackage {
13839            name: "GitHub".to_owned(),
13840            local_id: r"ARP\User\X64\GitHub".to_owned(),
13841            installed_version: "1.0.0".to_owned(),
13842            publisher: None,
13843            scope: Some("User".to_owned()),
13844            installer_category: Some("exe".to_owned()),
13845            install_location: None,
13846            package_family_names: Vec::new(),
13847            product_codes: Vec::new(),
13848            upgrade_codes: Vec::new(),
13849            correlated: None,
13850            winget_package_identifier: None,
13851            winget_source_identifier: None,
13852            installed_version_canonical: false,
13853            correlated_requires_explicit_upgrade: false,
13854            correlated_lacks_compatible_installer: false,
13855        };
13856        let candidates = vec![SearchMatch {
13857            source_name: "winget".to_owned(),
13858            source_kind: SourceKind::PreIndexed,
13859            id: "Git.Git.PreRelease".to_owned(),
13860            name: "Git".to_owned(),
13861            moniker: None,
13862            version: Some("2.54.0".to_owned()),
13863            channel: None,
13864            match_criteria: None,
13865        }];
13866
13867        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
13868    }
13869
13870    #[test]
13871    fn detects_available_upgrade_from_correlated_package() {
13872        let package = InstalledPackage {
13873            name: "AzCopy v10".to_owned(),
13874            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
13875            installed_version: "10.32.2".to_owned(),
13876            publisher: None,
13877            scope: Some("Machine".to_owned()),
13878            installer_category: Some("exe".to_owned()),
13879            install_location: None,
13880            package_family_names: Vec::new(),
13881            product_codes: Vec::new(),
13882            upgrade_codes: Vec::new(),
13883            correlated: Some(SearchMatch {
13884                source_name: "winget".to_owned(),
13885                source_kind: SourceKind::PreIndexed,
13886                id: "Microsoft.Azure.AZCopy.10".to_owned(),
13887                name: "AzCopy v10".to_owned(),
13888                moniker: None,
13889                version: Some("10.32.3".to_owned()),
13890                channel: None,
13891                match_criteria: None,
13892            }),
13893            winget_package_identifier: None,
13894            winget_source_identifier: None,
13895            installed_version_canonical: false,
13896            correlated_requires_explicit_upgrade: false,
13897            correlated_lacks_compatible_installer: false,
13898        };
13899
13900        assert!(installed_package_has_upgrade(&package));
13901    }
13902
13903    #[test]
13904    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
13905        let package = InstalledPackage {
13906            name: "Example Tool".to_owned(),
13907            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
13908            installed_version: "Unknown".to_owned(),
13909            publisher: None,
13910            scope: Some("User".to_owned()),
13911            installer_category: Some("exe".to_owned()),
13912            install_location: None,
13913            package_family_names: Vec::new(),
13914            product_codes: Vec::new(),
13915            upgrade_codes: Vec::new(),
13916            correlated: Some(SearchMatch {
13917                source_name: "winget".to_owned(),
13918                source_kind: SourceKind::PreIndexed,
13919                id: "Contoso.ExampleTool".to_owned(),
13920                name: "Example Tool".to_owned(),
13921                moniker: None,
13922                version: Some("2.0.0".to_owned()),
13923                channel: None,
13924                match_criteria: None,
13925            }),
13926            winget_package_identifier: None,
13927            winget_source_identifier: None,
13928            installed_version_canonical: false,
13929            correlated_requires_explicit_upgrade: false,
13930            correlated_lacks_compatible_installer: false,
13931        };
13932        let query = ListQuery {
13933            upgrade_only: true,
13934            include_unknown: true,
13935            ..Default::default()
13936        };
13937
13938        assert!(installed_package_matches_upgrade_filter(&package, &query));
13939        assert_eq!(
13940            list_match_from_installed(package, &[]).available_version.as_deref(),
13941            Some("2.0.0")
13942        );
13943    }
13944
13945    #[test]
13946    fn create_repair_list_query_includes_installed_selectors() {
13947        let request = RepairRequest {
13948            query: PackageQuery {
13949                query: Some("powertoys".to_owned()),
13950                id: Some("Microsoft.PowerToys".to_owned()),
13951                name: Some("PowerToys".to_owned()),
13952                moniker: Some("powertoys".to_owned()),
13953                source: Some("winget".to_owned()),
13954                exact: true,
13955                version: Some("0.98.1".to_owned()),
13956                install_scope: Some("Machine".to_owned()),
13957                ..PackageQuery::default()
13958            },
13959            manifest_path: None,
13960            product_code: Some("{GUID}".to_owned()),
13961            mode: InstallerMode::Silent,
13962            log_path: Some(PathBuf::from("repair.log")),
13963            accept_package_agreements: true,
13964            force: true,
13965            ignore_security_hash: true,
13966        };
13967
13968        let query = Repository::create_repair_list_query(&request);
13969        assert_eq!(query.query.as_deref(), Some("powertoys"));
13970        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
13971        assert_eq!(query.name.as_deref(), Some("PowerToys"));
13972        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
13973        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
13974        assert_eq!(query.version.as_deref(), Some("0.98.1"));
13975        assert_eq!(query.source.as_deref(), Some("winget"));
13976        assert!(query.exact);
13977        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
13978        assert_eq!(query.count, Some(100));
13979    }
13980
13981    #[test]
13982    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
13983        let request = RepairRequest {
13984            query: PackageQuery {
13985                name: Some("PowerToys".to_owned()),
13986                source: Some("winget".to_owned()),
13987                locale: Some("en-US".to_owned()),
13988                installer_architecture: Some("x64".to_owned()),
13989                install_scope: Some("Machine".to_owned()),
13990                ..PackageQuery::default()
13991            },
13992            manifest_path: None,
13993            product_code: None,
13994            mode: InstallerMode::SilentWithProgress,
13995            log_path: Some(PathBuf::from("repair.log")),
13996            accept_package_agreements: true,
13997            force: false,
13998            ignore_security_hash: true,
13999        };
14000        let installed = ListMatch {
14001            name: "PowerToys".to_owned(),
14002            id: "Microsoft.PowerToys".to_owned(),
14003            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
14004            installed_version: "0.98.1".to_owned(),
14005            available_version: Some("0.98.2".to_owned()),
14006            source_name: Some("winget".to_owned()),
14007            publisher: None,
14008            scope: Some("Machine".to_owned()),
14009            installer_category: Some("exe".to_owned()),
14010            install_location: None,
14011            package_family_names: Vec::new(),
14012            product_codes: Vec::new(),
14013            upgrade_codes: Vec::new(),
14014        };
14015
14016        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
14017        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
14018        assert!(install_request.query.query.is_none());
14019        assert!(install_request.query.name.is_none());
14020        assert!(install_request.query.moniker.is_none());
14021        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
14022        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
14023        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
14024        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
14025        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
14026        assert!(install_request.query.exact);
14027        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
14028        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
14029        assert!(install_request.accept_package_agreements);
14030        assert!(install_request.force);
14031        assert!(install_request.ignore_security_hash);
14032    }
14033
14034    #[test]
14035    fn source_scoped_pin_operations_round_trip() {
14036        let app_root = temp_app_root("pins");
14037        let result = (|| -> Result<()> {
14038            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14039            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
14040            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
14041            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
14042
14043            assert_eq!(repository.list_pins(None)?.len(), 3);
14044            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
14045            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
14046
14047            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
14048            assert_eq!(repository.list_pins(None)?.len(), 2);
14049            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
14050
14051            repository.reset_pins(Some("test"))?;
14052            let remaining = repository.list_pins(None)?;
14053            assert_eq!(remaining.len(), 1);
14054            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
14055
14056            repository.reset_pins(None)?;
14057            assert!(repository.list_pins(None)?.is_empty());
14058            Ok(())
14059        })();
14060
14061        let _ = fs::remove_dir_all(&app_root);
14062        result.expect("pin round trip");
14063    }
14064
14065    #[test]
14066    fn pin_operations_work_with_packaged_pin_schema() {
14067        let app_root = temp_app_root("pins_packaged_schema");
14068        let result = (|| -> Result<()> {
14069            let db_path = pins_db_path(&app_root);
14070            if let Some(parent) = db_path.parent() {
14071                fs::create_dir_all(parent)?;
14072            }
14073
14074            let conn = Repository::open_sqlite_connection(db_path.clone())?;
14075            execute_batch_sql(
14076                &conn,
14077                "CREATE TABLE pin (
14078                    package_id TEXT NOT NULL,
14079                    source_id TEXT NOT NULL,
14080                    type INTEGER NOT NULL,
14081                    version TEXT NOT NULL,
14082                    PRIMARY KEY (package_id, source_id)
14083                )",
14084            )?;
14085            conn.cacheflush().context("failed to flush pin database")?;
14086            drop(conn);
14087
14088            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14089            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
14090
14091            let pins = repository.list_pins(None)?;
14092            assert_eq!(pins.len(), 1);
14093            assert_eq!(pins[0].package_id, "Contoso.Tool");
14094            assert_eq!(pins[0].version, "1.2.*");
14095            assert_eq!(pins[0].source_id, "winget");
14096            assert_eq!(pins[0].pin_type, PinType::Gating);
14097
14098            let conn = Repository::open_sqlite_connection(db_path)?;
14099            let stored_pin = query_optional_value(
14100                &conn,
14101                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
14102                vec![
14103                    SqlValue::Text("Contoso.Tool".to_owned()),
14104                    SqlValue::Text("winget".to_owned()),
14105                ],
14106                |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
14107            )?
14108            .ok_or_else(|| anyhow!("expected stored pin"))?;
14109            assert_eq!(stored_pin.0, 3);
14110            assert_eq!(stored_pin.1, "1.2.*");
14111
14112            Ok(())
14113        })();
14114
14115        let _ = fs::remove_dir_all(&app_root);
14116        result.expect("packaged pin schema round trip");
14117    }
14118
14119    #[test]
14120    fn version_pin_patterns_match_exact_and_prefix_values() {
14121        assert!(version_matches_pin_pattern("1.2.3", "*"));
14122        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
14123        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
14124        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
14125        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
14126    }
14127
14128    #[test]
14129    fn source_specific_pins_override_source_agnostic_pins() {
14130        let item = ListMatch {
14131            name: "Contoso Tool".to_owned(),
14132            id: "Contoso.Tool".to_owned(),
14133            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14134            installed_version: "1.0.0".to_owned(),
14135            available_version: Some("2.0.0".to_owned()),
14136            source_name: Some("winget".to_owned()),
14137            publisher: None,
14138            scope: None,
14139            installer_category: None,
14140            install_location: None,
14141            package_family_names: Vec::new(),
14142            product_codes: Vec::new(),
14143            upgrade_codes: Vec::new(),
14144        };
14145        let pins = vec![
14146            PinRecord {
14147                package_id: "Contoso.Tool".to_owned(),
14148                version: "*".to_owned(),
14149                source_id: String::new(),
14150                pin_type: PinType::Blocking,
14151            },
14152            PinRecord {
14153                package_id: "Contoso.Tool".to_owned(),
14154                version: "2.0.*".to_owned(),
14155                source_id: "winget".to_owned(),
14156                pin_type: PinType::Pinning,
14157            },
14158        ];
14159
14160        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
14161    }
14162
14163    #[test]
14164    fn blocking_pin_blocks_upgrade() {
14165        let item = ListMatch {
14166            name: "Contoso Tool".to_owned(),
14167            id: "Contoso.Tool".to_owned(),
14168            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14169            installed_version: "1.0.0".to_owned(),
14170            available_version: Some("2.0.0".to_owned()),
14171            source_name: Some("winget".to_owned()),
14172            publisher: None,
14173            scope: None,
14174            installer_category: None,
14175            install_location: None,
14176            package_family_names: Vec::new(),
14177            product_codes: Vec::new(),
14178            upgrade_codes: Vec::new(),
14179        };
14180        let pins = vec![PinRecord {
14181            package_id: "Contoso.Tool".to_owned(),
14182            version: "*".to_owned(),
14183            source_id: "winget".to_owned(),
14184            pin_type: PinType::Blocking,
14185        }];
14186
14187        assert!(is_upgrade_blocked_by_pin(&item, &pins));
14188    }
14189
14190    #[cfg(windows)]
14191    #[test]
14192    fn parses_msix_package_full_name_into_version_and_family() {
14193        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
14194            .expect("package metadata");
14195
14196        assert_eq!(parsed.version, "0.98.1.0");
14197        assert_eq!(parsed.resource_id, "");
14198        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
14199        assert!(!is_msix_split_resource_package(&parsed.resource_id));
14200    }
14201
14202    #[cfg(windows)]
14203    #[test]
14204    fn classifies_msix_split_resource_package() {
14205        let parsed = parse_msix_package_full_name(
14206            "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe",
14207        )
14208        .expect("package metadata");
14209
14210        assert_eq!(parsed.version, "1.97.17002.0");
14211        assert_eq!(parsed.resource_id, "split.scale-125");
14212        assert_eq!(
14213            parsed.family_name,
14214            "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe"
14215        );
14216        assert!(is_msix_split_resource_package(&parsed.resource_id));
14217    }
14218
14219    #[cfg(windows)]
14220    #[test]
14221    fn recognizes_windows_system_paths() {
14222        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
14223        assert!(!is_windows_system_path(
14224            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
14225        ));
14226    }
14227
14228    #[test]
14229    fn search_ranking_prefers_exact_name_over_tag_match() {
14230        let query = PackageQuery {
14231            query: Some("PowerToys".to_owned()),
14232            ..Default::default()
14233        };
14234        let exact_name = SearchMatch {
14235            source_name: "winget".to_owned(),
14236            source_kind: SourceKind::PreIndexed,
14237            id: "Microsoft.PowerToys".to_owned(),
14238            name: "PowerToys".to_owned(),
14239            moniker: Some("powertoys".to_owned()),
14240            version: Some("0.98.1".to_owned()),
14241            channel: None,
14242            match_criteria: Some("Moniker: powertoys".to_owned()),
14243        };
14244        let tag_match = SearchMatch {
14245            source_name: "winget".to_owned(),
14246            source_kind: SourceKind::PreIndexed,
14247            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
14248            name: "QR Codes for Command Palette".to_owned(),
14249            moniker: None,
14250            version: Some("0.4.0.0".to_owned()),
14251            channel: None,
14252            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
14253        };
14254
14255        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
14256    }
14257
14258    #[test]
14259    fn select_best_text_match_prefers_exact_tag_value() {
14260        let values = vec![
14261            "microsoft-powertoys".to_owned(),
14262            "powertoys-run".to_owned(),
14263            "powertoys".to_owned(),
14264        ];
14265
14266        assert_eq!(
14267            select_best_text_match(values, "PowerToys", false).as_deref(),
14268            Some("powertoys")
14269        );
14270    }
14271
14272    #[test]
14273    fn search_match_criteria_is_blank_for_name_match() {
14274        let query = PackageQuery {
14275            query: Some("PowerToys".to_owned()),
14276            ..Default::default()
14277        };
14278
14279        let result = infer_match_criteria(
14280            "Microsoft.PowerToys",
14281            "PowerToys",
14282            Some("powertoys"),
14283            &query,
14284            SearchSemantics::Many,
14285            |_| Ok(Some("powertoys".to_owned())),
14286            |_| Ok(None),
14287        )
14288        .expect("match criteria");
14289
14290        assert_eq!(result, None);
14291    }
14292
14293    #[test]
14294    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
14295        let query = PackageQuery {
14296            query: Some("PowerToys".to_owned()),
14297            ..Default::default()
14298        };
14299        let unknown_version = SearchMatch {
14300            source_name: "msstore".to_owned(),
14301            source_kind: SourceKind::Rest,
14302            id: "XP89DCGQ3K6VLD".to_owned(),
14303            name: "Microsoft PowerToys".to_owned(),
14304            moniker: None,
14305            version: Some("Unknown".to_owned()),
14306            channel: None,
14307            match_criteria: None,
14308        };
14309        let tag_match = SearchMatch {
14310            source_name: "winget".to_owned(),
14311            source_kind: SourceKind::PreIndexed,
14312            id: "Riri.QRCodeforCmdPal".to_owned(),
14313            name: "QR Code for CmdPal".to_owned(),
14314            moniker: None,
14315            version: Some("0.0.1.0".to_owned()),
14316            channel: None,
14317            match_criteria: Some("Tag: powertoys".to_owned()),
14318        };
14319
14320        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
14321    }
14322
14323    #[test]
14324    fn search_source_fetch_results_ignore_small_display_count() {
14325        let query = PackageQuery {
14326            count: Some(5),
14327            ..Default::default()
14328        };
14329
14330        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
14331        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
14332    }
14333
14334    #[test]
14335    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
14336        let query = PackageQuery {
14337            query: Some("PowerToys".to_owned()),
14338            ..Default::default()
14339        };
14340        let prefix_name = SearchMatch {
14341            source_name: "winget".to_owned(),
14342            source_kind: SourceKind::PreIndexed,
14343            id: "advaith.CurrencyConverterPowerToys".to_owned(),
14344            name: "PowerToys-Run-Currency-Converter".to_owned(),
14345            moniker: None,
14346            version: Some("1.5.4".to_owned()),
14347            channel: None,
14348            match_criteria: None,
14349        };
14350        let exact_tag = SearchMatch {
14351            source_name: "winget".to_owned(),
14352            source_kind: SourceKind::PreIndexed,
14353            id: "Riri.QRCodeforCmdPal".to_owned(),
14354            name: "QR Code for CmdPal".to_owned(),
14355            moniker: None,
14356            version: Some("0.0.1.0".to_owned()),
14357            channel: None,
14358            match_criteria: Some("Tag: powertoys".to_owned()),
14359        };
14360
14361        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
14362    }
14363
14364    #[test]
14365    fn list_sort_prefers_main_package_then_sparse_app() {
14366        let main = InstalledPackage {
14367            name: "PowerToys (Preview) x64".to_owned(),
14368            local_id: r"ARP\User\X64\PowerToys".to_owned(),
14369            installed_version: "0.98.1".to_owned(),
14370            publisher: None,
14371            scope: Some("User".to_owned()),
14372            installer_category: Some("exe".to_owned()),
14373            install_location: None,
14374            package_family_names: Vec::new(),
14375            product_codes: Vec::new(),
14376            upgrade_codes: Vec::new(),
14377            correlated: None,
14378            winget_package_identifier: None,
14379            winget_source_identifier: None,
14380            installed_version_canonical: false,
14381            correlated_requires_explicit_upgrade: false,
14382            correlated_lacks_compatible_installer: false,
14383        };
14384        let sparse = InstalledPackage {
14385            name: "PowerToys.SparseApp".to_owned(),
14386            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14387            installed_version: "0.98.1.0".to_owned(),
14388            publisher: None,
14389            scope: Some("User".to_owned()),
14390            installer_category: Some("msix".to_owned()),
14391            install_location: None,
14392            package_family_names: Vec::new(),
14393            product_codes: Vec::new(),
14394            upgrade_codes: Vec::new(),
14395            correlated: None,
14396            winget_package_identifier: None,
14397            winget_source_identifier: None,
14398            installed_version_canonical: false,
14399            correlated_requires_explicit_upgrade: false,
14400            correlated_lacks_compatible_installer: false,
14401        };
14402        let extension = InstalledPackage {
14403            name: "PowerToys FileLocksmith Context Menu".to_owned(),
14404            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14405            installed_version: "0.98.1.0".to_owned(),
14406            publisher: None,
14407            scope: Some("User".to_owned()),
14408            installer_category: Some("msix".to_owned()),
14409            install_location: None,
14410            package_family_names: Vec::new(),
14411            product_codes: Vec::new(),
14412            upgrade_codes: Vec::new(),
14413            correlated: None,
14414            winget_package_identifier: None,
14415            winget_source_identifier: None,
14416            installed_version_canonical: false,
14417            correlated_requires_explicit_upgrade: false,
14418            correlated_lacks_compatible_installer: false,
14419        };
14420
14421        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
14422        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
14423    }
14424
14425    #[test]
14426    #[cfg(windows)]
14427    fn arp_subkey_name_extracts_registry_subkey() {
14428        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
14429        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
14430    }
14431
14432    #[test]
14433    #[cfg(windows)]
14434    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
14435        let installed = ListMatch {
14436            name: "ShareX".to_owned(),
14437            id: "ShareX.ShareX".to_owned(),
14438            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
14439            installed_version: "19.0.2".to_owned(),
14440            available_version: None,
14441            source_name: Some("winget".to_owned()),
14442            publisher: Some("ShareX Team".to_owned()),
14443            scope: Some("Machine".to_owned()),
14444            installer_category: Some("exe".to_owned()),
14445            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
14446            package_family_names: Vec::new(),
14447            product_codes: Vec::new(),
14448            upgrade_codes: Vec::new(),
14449        };
14450
14451        assert!(registry_entry_matches_installed_package(
14452            "ShareX", "ShareX", None, &installed
14453        ));
14454        assert!(!registry_entry_matches_installed_package(
14455            "ShareX.ShareX",
14456            "ShareX.ShareX",
14457            None,
14458            &installed
14459        ));
14460    }
14461
14462    #[test]
14463    #[cfg(windows)]
14464    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
14465        assert_eq!(
14466            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
14467            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
14468        );
14469        assert_eq!(
14470            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
14471            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
14472        );
14473        assert_eq!(
14474            r#""C:\Program Files\ShareX\unins000.exe""#,
14475            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
14476        );
14477        assert_eq!(
14478            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14479            build_uninstall_command(
14480                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14481                true,
14482                false,
14483            )
14484        );
14485    }
14486
14487    #[test]
14488    #[cfg(windows)]
14489    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
14490        let installed = ListMatch {
14491            name: "lazygit".to_owned(),
14492            id: "JesseDuffield.lazygit".to_owned(),
14493            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
14494            installed_version: "0.61.1".to_owned(),
14495            available_version: None,
14496            source_name: Some("winget".to_owned()),
14497            publisher: Some("Jesse Duffield".to_owned()),
14498            scope: Some("User".to_owned()),
14499            installer_category: Some("exe".to_owned()),
14500            install_location: Some(
14501                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
14502            ),
14503            package_family_names: Vec::new(),
14504            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
14505            upgrade_codes: Vec::new(),
14506        };
14507        let mut request = UninstallRequest::new(PackageQuery {
14508            id: Some("JesseDuffield.lazygit".to_owned()),
14509            install_scope: Some("user".to_owned()),
14510            ..PackageQuery::default()
14511        });
14512        request.mode = InstallerMode::Silent;
14513
14514        assert_eq!(
14515            Some(vec![
14516                "uninstall".to_owned(),
14517                "--id".to_owned(),
14518                "JesseDuffield.lazygit".to_owned(),
14519                "--exact".to_owned(),
14520                "--disable-interactivity".to_owned(),
14521                "--source".to_owned(),
14522                "winget".to_owned(),
14523                "--scope".to_owned(),
14524                "user".to_owned(),
14525                "--silent".to_owned(),
14526            ]),
14527            build_winget_uninstall_arguments(&installed, &request)
14528        );
14529
14530        assert_eq!(
14531            Some(vec![
14532                "uninstall".to_owned(),
14533                "--id".to_owned(),
14534                "JesseDuffield.lazygit".to_owned(),
14535                "--exact".to_owned(),
14536                "--disable-interactivity".to_owned(),
14537                "--source".to_owned(),
14538                "winget".to_owned(),
14539                "--silent".to_owned(),
14540            ]),
14541            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
14542        );
14543    }
14544
14545    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
14546        PackageVersionDataEntry {
14547            version: version.to_owned(),
14548            manifest_relative_path: format!("manifests/x/{version}"),
14549            manifest_hash: "00".repeat(32),
14550            arp_min_version: arp_min.map(str::to_owned),
14551            arp_max_version: arp_max.map(str::to_owned),
14552        }
14553    }
14554
14555    fn v1_version(version: &str, channel: &str) -> V1VersionRow {
14556        V1VersionRow {
14557            version: version.to_owned(),
14558            channel: channel.to_owned(),
14559            pathpart_id: 0,
14560            manifest_hash: None,
14561        }
14562    }
14563
14564    #[derive(Clone)]
14565    struct TestHttpResponse {
14566        status: u16,
14567        body: Vec<u8>,
14568        source_version: Option<String>,
14569        etag: Option<String>,
14570        last_modified: Option<String>,
14571    }
14572
14573    struct TestHttpServer {
14574        addr: SocketAddr,
14575        routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14576        counts: Arc<Mutex<HashMap<String, usize>>>,
14577        running: Arc<AtomicBool>,
14578        handle: Option<JoinHandle<()>>,
14579    }
14580
14581    impl TestHttpServer {
14582        fn start() -> Self {
14583            let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
14584            let addr = listener.local_addr().expect("test HTTP server address");
14585            let routes = Arc::new(Mutex::new(HashMap::new()));
14586            let counts = Arc::new(Mutex::new(HashMap::new()));
14587            let running = Arc::new(AtomicBool::new(true));
14588            let thread_routes = Arc::clone(&routes);
14589            let thread_counts = Arc::clone(&counts);
14590            let thread_running = Arc::clone(&running);
14591            let handle = thread::spawn(move || {
14592                while thread_running.load(AtomicOrdering::SeqCst) {
14593                    let Ok((stream, _)) = listener.accept() else {
14594                        continue;
14595                    };
14596                    handle_test_http_request(stream, &thread_routes, &thread_counts);
14597                }
14598            });
14599
14600            Self {
14601                addr,
14602                routes,
14603                counts,
14604                running,
14605                handle: Some(handle),
14606            }
14607        }
14608
14609        fn url(&self) -> String {
14610            format!("http://{}", self.addr)
14611        }
14612
14613        fn set_bytes(&self, path: &str, body: Vec<u8>) {
14614            self.set_response(
14615                path,
14616                TestHttpResponse {
14617                    status: 200,
14618                    body,
14619                    source_version: None,
14620                    etag: None,
14621                    last_modified: None,
14622                },
14623            );
14624        }
14625
14626        fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
14627            self.set_source_package_with_validators(body, source_version, None, None);
14628        }
14629
14630        fn set_source_package_with_validators(
14631            &self,
14632            body: Vec<u8>,
14633            source_version: &str,
14634            etag: Option<&str>,
14635            last_modified: Option<&str>,
14636        ) {
14637            self.set_response(
14638                "/source.msix",
14639                TestHttpResponse {
14640                    status: 200,
14641                    body,
14642                    source_version: Some(source_version.to_owned()),
14643                    etag: etag.map(str::to_owned),
14644                    last_modified: last_modified.map(str::to_owned),
14645                },
14646            );
14647        }
14648
14649        fn set_status(&self, path: &str, status: u16) {
14650            self.set_response(
14651                path,
14652                TestHttpResponse {
14653                    status,
14654                    body: Vec::new(),
14655                    source_version: None,
14656                    etag: None,
14657                    last_modified: None,
14658                },
14659            );
14660        }
14661
14662        fn set_response(&self, path: &str, response: TestHttpResponse) {
14663            self.routes
14664                .lock()
14665                .expect("test HTTP routes")
14666                .insert(path.to_owned(), response);
14667        }
14668
14669        fn request_count(&self, path: &str) -> usize {
14670            self.counts
14671                .lock()
14672                .expect("test HTTP counts")
14673                .get(path)
14674                .copied()
14675                .unwrap_or_default()
14676        }
14677    }
14678
14679    impl Drop for TestHttpServer {
14680        fn drop(&mut self) {
14681            self.running.store(false, AtomicOrdering::SeqCst);
14682            let _ = TcpStream::connect(self.addr);
14683            if let Some(handle) = self.handle.take() {
14684                let _ = handle.join();
14685            }
14686        }
14687    }
14688
14689    fn handle_test_http_request(
14690        mut stream: TcpStream,
14691        routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14692        counts: &Arc<Mutex<HashMap<String, usize>>>,
14693    ) {
14694        let mut buffer = [0u8; 2048];
14695        let read = stream.read(&mut buffer).unwrap_or(0);
14696        let request = String::from_utf8_lossy(&buffer[..read]);
14697        let path = request
14698            .lines()
14699            .next()
14700            .and_then(|line| line.split_whitespace().nth(1))
14701            .unwrap_or("/");
14702        *counts
14703            .lock()
14704            .expect("test HTTP counts")
14705            .entry(path.to_owned())
14706            .or_default() += 1;
14707        let response = routes.lock().expect("test HTTP routes").get(path).cloned();
14708        let response = response.unwrap_or(TestHttpResponse {
14709            status: 404,
14710            body: Vec::new(),
14711            source_version: None,
14712            etag: None,
14713            last_modified: None,
14714        });
14715        let not_modified = response.status == 200
14716            && (response.etag.as_deref().is_some_and(|etag| {
14717                request_header_value(&request, "If-None-Match")
14718                    .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
14719            }) || response.last_modified.as_deref().is_some_and(|last_modified| {
14720                request_header_value(&request, "If-Modified-Since")
14721                    .is_some_and(|value| value.eq_ignore_ascii_case(last_modified))
14722            }));
14723        let status = if not_modified { 304 } else { response.status };
14724        let body = if not_modified {
14725            &[][..]
14726        } else {
14727            response.body.as_slice()
14728        };
14729        let reason = match status {
14730            200 => "OK",
14731            304 => "Not Modified",
14732            _ => "Not Found",
14733        };
14734        let mut headers = format!(
14735            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
14736            status,
14737            reason,
14738            body.len()
14739        );
14740        if let Some(source_version) = response.source_version {
14741            headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
14742        }
14743        if let Some(etag) = response.etag {
14744            headers.push_str(&format!("ETag: {etag}\r\n"));
14745        }
14746        if let Some(last_modified) = response.last_modified {
14747            headers.push_str(&format!("Last-Modified: {last_modified}\r\n"));
14748        }
14749        headers.push_str("\r\n");
14750        stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
14751        stream.write_all(body).expect("write test HTTP body");
14752    }
14753
14754    fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
14755        request.lines().find_map(|line| {
14756            let (header_name, value) = line.split_once(':')?;
14757            header_name.eq_ignore_ascii_case(name).then(|| value.trim())
14758        })
14759    }
14760
14761    fn test_source_record(source_url: String) -> SourceRecord {
14762        SourceRecord {
14763            name: "test-winget".to_owned(),
14764            kind: SourceKind::PreIndexed,
14765            arg: source_url,
14766            identifier: "Test.Source".to_owned(),
14767            trust_level: "Trusted".to_owned(),
14768            explicit: false,
14769            priority: 0,
14770            last_update: Some(Utc::now()),
14771            source_version: None,
14772            etag: None,
14773            last_modified: None,
14774        }
14775    }
14776
14777    fn open_test_preindexed_repository(
14778        app_root: &Path,
14779        source: &SourceRecord,
14780        interval: Option<Duration>,
14781    ) -> Repository {
14782        fs::create_dir_all(app_root).expect("create app root");
14783        save_store(
14784            app_root,
14785            &SourceStore {
14786                sources: vec![source.clone()],
14787            },
14788        )
14789        .expect("save source store");
14790        Repository::open_with_options(
14791            RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
14792        )
14793        .expect("open repository")
14794    }
14795
14796    fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
14797        let index_path = preindexed_index_path(app_root, source);
14798        if let Some(parent) = index_path.parent() {
14799            fs::create_dir_all(parent).expect("create source state directory");
14800        }
14801        fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
14802    }
14803
14804    #[test]
14805    fn replace_file_failure_keeps_existing_target() {
14806        let root = temp_app_root("replace_file_failure");
14807        fs::create_dir_all(&root).expect("create replace root");
14808        let target = root.join("index.db");
14809        let missing_source = root.join("missing.tmp");
14810        fs::write(&target, b"old-index").expect("write existing target");
14811
14812        let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
14813
14814        assert!(format!("{error:#}").contains("failed to replace"));
14815        assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
14816        let _ = fs::remove_dir_all(root);
14817    }
14818
14819    #[cfg(windows)]
14820    #[test]
14821    fn replace_file_succeeds_while_target_is_open() {
14822        use std::os::windows::fs::OpenOptionsExt;
14823        // FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE — the sharing
14824        // mode SQLite/Turso uses. An atomic MOVEFILE_REPLACE_EXISTING is denied
14825        // while this handle is open, so the stage-aside fallback must take over.
14826        const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
14827
14828        let root = temp_app_root("replace_file_open_target");
14829        fs::create_dir_all(&root).expect("create replace root");
14830        let target = root.join("index.db");
14831        let source = root.join("index.new.tmp");
14832        fs::write(&target, b"old-index").expect("write existing target");
14833        fs::write(&source, b"new-index").expect("write replacement");
14834
14835        let held = OpenOptions::new()
14836            .read(true)
14837            .share_mode(SHARE_ALL)
14838            .open(&target)
14839            .expect("open target with share-delete");
14840
14841        replace_file(&source, &target).expect("stage-aside replace should succeed");
14842
14843        assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
14844        drop(held);
14845        let _ = fs::remove_dir_all(root);
14846    }
14847
14848    fn make_source_package(versions: &[&str]) -> Vec<u8> {
14849        let cursor = Cursor::new(Vec::new());
14850        let mut archive = zip::ZipWriter::new(cursor);
14851        let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
14852        archive
14853            .start_file("Public/index.db", options)
14854            .expect("start index entry");
14855        archive
14856            .write_all(&make_v1_index_bytes(versions))
14857            .expect("write index entry");
14858        archive.finish().expect("finish source package").into_inner()
14859    }
14860
14861    fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
14862        let root = temp_app_root("preindexed_index_bytes");
14863        fs::create_dir_all(&root).expect("create index temp root");
14864        let db_path = root.join("index.db");
14865        let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
14866        let mut sql = String::from(
14867            "CREATE TABLE ids (id TEXT);
14868             CREATE TABLE names (name TEXT);
14869             CREATE TABLE monikers (moniker TEXT);
14870             CREATE TABLE versions (version TEXT);
14871             CREATE TABLE channels (channel TEXT);
14872             CREATE TABLE pathparts (parent INT64, pathpart TEXT);
14873             CREATE TABLE manifest (
14874                 id INT64,
14875                 name INT64,
14876                 moniker INT64,
14877                 version INT64,
14878                 channel INT64,
14879                 pathpart INT64
14880             );
14881             INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
14882             INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
14883             INSERT INTO channels(rowid, channel) VALUES (1, '');
14884             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
14885             INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
14886        );
14887        for (index, version) in versions.iter().enumerate() {
14888            let rowid = index + 1;
14889            sql.push_str(&format!(
14890                "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
14891                 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
14892            ));
14893        }
14894        execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
14895        let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
14896            .expect("checkpoint preindexed V1 database");
14897        connection.cacheflush().expect("flush preindexed V1 database");
14898        drop(connection);
14899        let bytes = fs::read(&db_path).expect("read index bytes");
14900        let _ = fs::remove_dir_all(root);
14901        bytes
14902    }
14903
14904    fn test_manifest_yaml(version: &str) -> Vec<u8> {
14905        format!(
14906            "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
14907        )
14908        .into_bytes()
14909    }
14910
14911    fn show_test_package(version: Option<&str>) -> PackageQuery {
14912        PackageQuery {
14913            id: Some("Test.Package".to_owned()),
14914            source: Some("test-winget".to_owned()),
14915            exact: true,
14916            version: version.map(str::to_owned),
14917            ..PackageQuery::default()
14918        }
14919    }
14920
14921    #[test]
14922    fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
14923        let app_root = temp_app_root("preindexed_explicit_refresh");
14924        let server = TestHttpServer::start();
14925        server.set_status("/source2.msix", 404);
14926        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
14927        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14928        let source = test_source_record(server.url());
14929        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14930        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14931
14932        let result = repository
14933            .show(&show_test_package(Some("2.0.0")))
14934            .expect("show after refresh");
14935
14936        assert_eq!(result.manifest.version, "2.0.0");
14937        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
14938        let _ = fs::remove_dir_all(app_root);
14939    }
14940
14941    #[test]
14942    fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
14943        let app_root = temp_app_root("preindexed_explicit_still_missing");
14944        let server = TestHttpServer::start();
14945        server.set_status("/source2.msix", 404);
14946        server.set_source_package(make_source_package(&["1.0.0"]), "same");
14947        let source = test_source_record(server.url());
14948        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14949        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14950
14951        let error = repository
14952            .show(&show_test_package(Some("2.0.0")))
14953            .expect_err("missing explicit version must fail");
14954
14955        assert!(format!("{error:#}").contains("2.0.0"));
14956        let _ = fs::remove_dir_all(app_root);
14957    }
14958
14959    #[test]
14960    fn preindexed_stale_index_refreshes_for_latest_requests() {
14961        let app_root = temp_app_root("preindexed_ttl_refresh");
14962        let server = TestHttpServer::start();
14963        server.set_status("/source2.msix", 404);
14964        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14965        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14966        let mut source = test_source_record(server.url());
14967        source.last_update = Some(Utc::now() - Duration::days(2));
14968        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14969        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14970
14971        let result = repository
14972            .show(&show_test_package(None))
14973            .expect("show latest after TTL refresh");
14974
14975        assert_eq!(result.manifest.version, "2.0.0");
14976        assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
14977        let _ = fs::remove_dir_all(app_root);
14978    }
14979
14980    #[test]
14981    fn preindexed_stale_index_not_modified_keeps_local_index() {
14982        let app_root = temp_app_root("preindexed_ttl_not_modified");
14983        let server = TestHttpServer::start();
14984        server.set_status("/source2.msix", 404);
14985        server.set_source_package_with_validators(
14986            make_source_package(&["1.0.0", "2.0.0"]),
14987            "fresh",
14988            Some("\"same-index\""),
14989            Some("Wed, 03 Jun 2026 12:00:00 GMT"),
14990        );
14991        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14992        let mut source = test_source_record(server.url());
14993        source.last_update = Some(Utc::now() - Duration::days(2));
14994        source.etag = Some("\"same-index\"".to_owned());
14995        source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
14996        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14997        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14998
14999        let result = repository
15000            .show(&show_test_package(None))
15001            .expect("show latest from not-modified stale index");
15002
15003        let refreshed_source = &repository.list_sources()[0];
15004        assert_eq!(result.manifest.version, "1.0.0");
15005        assert_eq!(server.request_count("/source.msix"), 1);
15006        assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\""));
15007        assert_eq!(
15008            refreshed_source.last_modified.as_deref(),
15009            Some("Wed, 03 Jun 2026 12:00:00 GMT")
15010        );
15011        let _ = fs::remove_dir_all(app_root);
15012    }
15013
15014    #[test]
15015    fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
15016        let app_root = temp_app_root("preindexed_fresh_mtime");
15017        let server = TestHttpServer::start();
15018        server.set_status("/source2.msix", 404);
15019        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15020        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15021        let mut source = test_source_record(server.url());
15022        source.last_update = Some(Utc::now() - Duration::days(2));
15023        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15024        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
15025
15026        let result = repository
15027            .show(&show_test_package(None))
15028            .expect("show latest from fresh index mtime");
15029
15030        assert_eq!(result.manifest.version, "1.0.0");
15031        assert_eq!(server.request_count("/source.msix"), 0);
15032        let _ = fs::remove_dir_all(app_root);
15033    }
15034
15035    #[test]
15036    fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
15037        let app_root = temp_app_root("preindexed_ttl_fallback");
15038        let server = TestHttpServer::start();
15039        server.set_status("/source2.msix", 404);
15040        server.set_status("/source.msix", 404);
15041        server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15042        let mut source = test_source_record(server.url());
15043        source.last_update = Some(Utc::now() - Duration::days(2));
15044        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15045        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15046
15047        let result = repository
15048            .show(&show_test_package(None))
15049            .expect("show latest from stale cache after refresh failure");
15050
15051        assert_eq!(result.manifest.version, "1.0.0");
15052        let _ = fs::remove_dir_all(app_root);
15053    }
15054
15055    #[test]
15056    fn explicit_source_search_failure_is_not_reported_as_no_match() {
15057        let app_root = temp_app_root("preindexed_source_search_failure");
15058        let server = TestHttpServer::start();
15059        server.set_status("/source2.msix", 404);
15060        server.set_status("/source.msix", 404);
15061        let source = test_source_record(server.url());
15062        let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15063
15064        let error = repository
15065            .show(&show_test_package(None))
15066            .expect_err("source failure must surface");
15067        let message = format!("{error:#}");
15068
15069        assert!(message.contains("failed to search source 'test-winget'"));
15070        assert!(message.contains("source.msix"));
15071        assert!(!message.contains("no package matched the supplied query"));
15072        let _ = fs::remove_dir_all(app_root);
15073    }
15074
15075    #[test]
15076    fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
15077        let app_root = temp_app_root("preindexed_no_double_refresh");
15078        let server = TestHttpServer::start();
15079        server.set_status("/source2.msix", 404);
15080        server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15081        let mut source = test_source_record(server.url());
15082        source.last_update = Some(Utc::now() - Duration::days(2));
15083        write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15084        let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15085
15086        let error = repository
15087            .show(&show_test_package(Some("3.0.0")))
15088            .expect_err("still-missing explicit version must fail after one stale refresh");
15089
15090        assert!(format!("{error:#}").contains("3.0.0"));
15091        assert_eq!(server.request_count("/source.msix"), 1);
15092        let _ = fs::remove_dir_all(app_root);
15093    }
15094
15095    #[test]
15096    fn select_v2_version_without_explicit_filters_returns_latest() {
15097        let versions = vec![
15098            version_entry("1.0.0", None, None),
15099            version_entry("1.2.0", None, None),
15100            version_entry("1.1.0", None, None),
15101        ];
15102
15103        let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
15104
15105        assert_eq!(selected.version, "1.2.0");
15106    }
15107
15108    #[test]
15109    fn select_v2_version_with_missing_requested_version_throws() {
15110        let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
15111
15112        let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
15113            .expect_err("missing requested version");
15114
15115        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15116        assert!(error.to_string().contains("9.9.9"));
15117    }
15118
15119    #[test]
15120    fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
15121        let versions = vec![version_entry("1.2.0", None, None)];
15122
15123        let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15124            .expect_err("unsupported channel request");
15125
15126        assert!(error.to_string().contains("beta"));
15127        assert!(error.to_string().contains("channel metadata"));
15128    }
15129
15130    #[test]
15131    fn select_v1_version_channel_only_returns_latest_matching_channel() {
15132        let versions = vec![
15133            v1_version("1.2.0", "stable"),
15134            v1_version("1.3.0", "stable"),
15135            v1_version("2.0.0", "preview"),
15136        ];
15137
15138        let selected =
15139            select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
15140
15141        assert_eq!(selected.version, "1.3.0");
15142    }
15143
15144    #[test]
15145    fn select_rest_version_channel_mismatch_throws() {
15146        let versions = vec![
15147            VersionKey {
15148                version: "1.2.0".to_owned(),
15149                channel: "stable".to_owned(),
15150            },
15151            VersionKey {
15152                version: "1.1.0".to_owned(),
15153                channel: "beta".to_owned(),
15154            },
15155        ];
15156
15157        let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15158            .expect_err("missing version and channel pair");
15159
15160        assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15161    }
15162
15163    #[test]
15164    fn map_arp_version_returns_catalog_version_inside_range() {
15165        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
15166        // `10.1.826.23019`. compare_version on that against the catalog Version
15167        // `10.0.108` claims installed > available, so without this mapping the
15168        // upgrade was silently dropped.
15169        let entries = vec![
15170            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15171            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15172            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
15173        ];
15174
15175        assert_eq!(
15176            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
15177            Some("10.0.108")
15178        );
15179    }
15180
15181    #[test]
15182    fn map_arp_version_returns_none_when_no_range_matches() {
15183        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
15184        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
15185        // original installed version (and accept that no upgrade can be
15186        // computed) — same as winget's behavior.
15187        let entries = vec![
15188            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15189            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15190        ];
15191
15192        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
15193    }
15194
15195    #[test]
15196    fn map_arp_version_reports_less_than_latest_for_coarse_display_version() {
15197        // Snagit 2025 reports ARP DisplayVersion `25.4.1`, but the catalog's
15198        // latest ARP build is `25.4.1.10325`. winget renders this as
15199        // installed `< 25.4.1` so the latest installer remains applicable.
15200        let entries = vec![
15201            version_entry("25.4.1", Some("25.4.1.10325"), Some("25.4.1.10325")),
15202            version_entry("25.4.0", Some("25.4.0"), Some("25.4.0.8498")),
15203        ];
15204
15205        assert!(map_arp_version_to_catalog(&entries, "25.4.1").is_none());
15206        assert_eq!(
15207            less_than_latest_arp_anchored_version(&entries, "25.4.1").as_deref(),
15208            Some("< 25.4.1")
15209        );
15210        assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none());
15211    }
15212
15213    #[test]
15214    fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() {
15215        // WindowsAppRuntime packages can be serviced beyond the latest winget
15216        // catalog versionData range. winget renders those as `> latest` and
15217        // does not report an available downgrade.
15218        let entries = vec![
15219            version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")),
15220            version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")),
15221        ];
15222
15223        assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none());
15224        assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none());
15225        assert_eq!(
15226            greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(),
15227            Some("> 1.5.8")
15228        );
15229        assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none());
15230
15231        let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))];
15232        assert!(
15233            greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none()
15234        );
15235    }
15236
15237    #[test]
15238    fn map_arp_version_skips_entries_missing_arp_bounds() {
15239        // Older catalog packages predate AppsAndFeaturesEntries and have no
15240        // aMiV/aMaV. Those entries must be ignored — not silently treated as
15241        // an unbounded range.
15242        let entries = vec![
15243            version_entry("9.0.300", None, None),
15244            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
15245        ];
15246
15247        assert_eq!(
15248            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
15249            Some("9.0.117")
15250        );
15251        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
15252    }
15253
15254    #[test]
15255    fn map_arp_version_handles_inclusive_range_endpoints() {
15256        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
15257        // must still map.
15258        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
15259
15260        assert_eq!(
15261            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
15262            Some("3.14.5")
15263        );
15264        assert_eq!(
15265            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
15266            Some("3.14.5")
15267        );
15268        assert_eq!(
15269            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
15270            Some("3.14.5")
15271        );
15272    }
15273
15274    #[test]
15275    fn map_arp_version_ignores_unknown_marker() {
15276        // collect_installed_packages writes the literal "Unknown" when ARP has
15277        // no DisplayVersion. Trying to map that would falsely match the first
15278        // entry whose aMiV..aMaV string-compares to include "Unknown".
15279        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
15280
15281        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
15282        assert!(map_arp_version_to_catalog(&entries, "").is_none());
15283    }
15284
15285    #[test]
15286    fn latest_arp_anchored_skips_internal_versions() {
15287        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
15288        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
15289        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
15290        // it against an installed canonical of `1.8.6` would falsely report
15291        // an upgrade. Returns the highest anchored Version instead.
15292        let entries = vec![
15293            version_entry("8000.836.2153.0", None, None),
15294            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
15295            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
15296            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
15297        ];
15298
15299        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
15300    }
15301
15302    #[test]
15303    fn latest_arp_anchored_returns_none_without_bounds() {
15304        // App Installer's versionData has no aMiV/aMaV — every version is a
15305        // user-facing MSIX Version. Caller must fall back to
15306        // `packages.latest_version` in this case.
15307        let entries = vec![
15308            version_entry("1.28.240.0", None, None),
15309            version_entry("1.27.470.0", None, None),
15310        ];
15311
15312        assert!(latest_arp_anchored_version(&entries).is_none());
15313    }
15314
15315    #[test]
15316    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
15317        let package = InstalledPackage {
15318            name: "AzCopy v10".to_owned(),
15319            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
15320            installed_version: "10.32.2".to_owned(),
15321            publisher: Some("Microsoft".to_owned()),
15322            scope: Some("Machine".to_owned()),
15323            installer_category: Some("exe".to_owned()),
15324            install_location: None,
15325            package_family_names: Vec::new(),
15326            product_codes: Vec::new(),
15327            upgrade_codes: Vec::new(),
15328            correlated: Some(SearchMatch {
15329                source_name: "winget".to_owned(),
15330                source_kind: SourceKind::PreIndexed,
15331                id: "Microsoft.Azure.AZCopy.10".to_owned(),
15332                name: "AzCopy v10".to_owned(),
15333                moniker: None,
15334                version: Some("10.32.3".to_owned()),
15335                channel: None,
15336                match_criteria: None,
15337            }),
15338            winget_package_identifier: None,
15339            winget_source_identifier: None,
15340            installed_version_canonical: false,
15341            correlated_requires_explicit_upgrade: false,
15342            correlated_lacks_compatible_installer: false,
15343        };
15344
15345        let item = list_match_from_installed(package, &[]);
15346
15347        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
15348        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
15349        assert_eq!(item.source_name.as_deref(), Some("winget"));
15350        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
15351    }
15352
15353    #[test]
15354    fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() {
15355        let rows = vec![
15356            ListMatch {
15357                name: "Example 1.0".to_owned(),
15358                id: "Example.Tool".to_owned(),
15359                local_id: r"ARP\Machine\X64\old".to_owned(),
15360                installed_version: "1.0.0".to_owned(),
15361                available_version: Some("3.0.0".to_owned()),
15362                source_name: Some("winget".to_owned()),
15363                publisher: None,
15364                scope: None,
15365                installer_category: None,
15366                install_location: None,
15367                package_family_names: Vec::new(),
15368                product_codes: Vec::new(),
15369                upgrade_codes: Vec::new(),
15370            },
15371            ListMatch {
15372                name: "Example 2.0".to_owned(),
15373                id: "Example.Tool".to_owned(),
15374                local_id: r"ARP\Machine\X64\new".to_owned(),
15375                installed_version: "2.0.0".to_owned(),
15376                available_version: Some("3.0.0".to_owned()),
15377                source_name: Some("winget".to_owned()),
15378                publisher: None,
15379                scope: None,
15380                installer_category: None,
15381                install_location: None,
15382                package_family_names: Vec::new(),
15383                product_codes: Vec::new(),
15384                upgrade_codes: Vec::new(),
15385            },
15386            ListMatch {
15387                name: "Other".to_owned(),
15388                id: "Other.Tool".to_owned(),
15389                local_id: r"ARP\Machine\X64\other".to_owned(),
15390                installed_version: "1.0.0".to_owned(),
15391                available_version: Some("2.0.0".to_owned()),
15392                source_name: Some("winget".to_owned()),
15393                publisher: None,
15394                scope: None,
15395                installer_category: None,
15396                install_location: None,
15397                package_family_names: Vec::new(),
15398                product_codes: Vec::new(),
15399                upgrade_codes: Vec::new(),
15400            },
15401        ];
15402
15403        let result = suppress_duplicate_available_versions(rows);
15404
15405        assert_eq!(result[0].available_version, None);
15406        assert_eq!(result[1].available_version.as_deref(), Some("3.0.0"));
15407        assert_eq!(result[2].available_version.as_deref(), Some("2.0.0"));
15408    }
15409
15410    #[test]
15411    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
15412        let package = InstalledPackage {
15413            name: "Atlassian CLI".to_owned(),
15414            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15415            installed_version: "1.3.18-stable".to_owned(),
15416            publisher: Some("Atlassian".to_owned()),
15417            scope: Some("User".to_owned()),
15418            installer_category: Some("exe".to_owned()),
15419            install_location: None,
15420            package_family_names: Vec::new(),
15421            product_codes: Vec::new(),
15422            upgrade_codes: Vec::new(),
15423            correlated: Some(SearchMatch {
15424                source_name: "winget".to_owned(),
15425                source_kind: SourceKind::PreIndexed,
15426                id: "Atlassian.AtlassianCLI".to_owned(),
15427                name: "Atlassian CLI".to_owned(),
15428                moniker: None,
15429                version: Some("1.3.18-stable".to_owned()),
15430                channel: None,
15431                match_criteria: Some("ProductCode".to_owned()),
15432            }),
15433            winget_package_identifier: None,
15434            winget_source_identifier: None,
15435            installed_version_canonical: false,
15436            correlated_requires_explicit_upgrade: false,
15437            correlated_lacks_compatible_installer: false,
15438        };
15439
15440        let item = list_match_from_installed(package, &[]);
15441
15442        assert_eq!(item.id, "Atlassian.AtlassianCLI");
15443        assert_eq!(
15444            item.local_id,
15445            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
15446        );
15447        assert_ne!(item.id, item.local_id);
15448        assert_eq!(item.source_name.as_deref(), Some("winget"));
15449    }
15450
15451    #[test]
15452    fn list_match_from_installed_derives_winget_portable_identity_from_local_id() {
15453        let package = InstalledPackage {
15454            name: "FFmpeg".to_owned(),
15455            local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15456            installed_version: "8.0".to_owned(),
15457            publisher: Some("Gyan".to_owned()),
15458            scope: Some("User".to_owned()),
15459            installer_category: Some("portable".to_owned()),
15460            install_location: None,
15461            package_family_names: Vec::new(),
15462            product_codes: Vec::new(),
15463            upgrade_codes: Vec::new(),
15464            correlated: None,
15465            winget_package_identifier: None,
15466            winget_source_identifier: None,
15467            installed_version_canonical: false,
15468            correlated_requires_explicit_upgrade: false,
15469            correlated_lacks_compatible_installer: false,
15470        };
15471        let sources = vec![SourceRecord {
15472            name: "winget".to_owned(),
15473            kind: SourceKind::PreIndexed,
15474            arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
15475            identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15476            trust_level: "Trusted".to_owned(),
15477            explicit: false,
15478            priority: 0,
15479            last_update: None,
15480            source_version: None,
15481            etag: None,
15482            last_modified: None,
15483        }];
15484
15485        let item = list_match_from_installed(package, &sources);
15486
15487        assert_eq!(item.id, "Gyan.FFmpeg");
15488        assert_eq!(
15489            item.local_id,
15490            r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe"
15491        );
15492        assert_eq!(item.source_name.as_deref(), Some("winget"));
15493    }
15494
15495    #[test]
15496    fn list_match_from_installed_uses_winget_arp_identity_metadata() {
15497        let package = InstalledPackage {
15498            name: "tessl".to_owned(),
15499            local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15500            installed_version: "0.77.0".to_owned(),
15501            publisher: Some("tessl.io".to_owned()),
15502            scope: Some("User".to_owned()),
15503            installer_category: Some("portable".to_owned()),
15504            install_location: None,
15505            package_family_names: Vec::new(),
15506            product_codes: Vec::new(),
15507            upgrade_codes: Vec::new(),
15508            correlated: None,
15509            winget_package_identifier: Some("tessl.tessl".to_owned()),
15510            winget_source_identifier: Some("api.winget.pro".to_owned()),
15511            installed_version_canonical: false,
15512            correlated_requires_explicit_upgrade: false,
15513            correlated_lacks_compatible_installer: false,
15514        };
15515
15516        let item = list_match_from_installed(package, &[]);
15517
15518        assert_eq!(item.id, "tessl.tessl");
15519        assert_eq!(item.source_name.as_deref(), Some("api.winget.pro"));
15520    }
15521
15522    #[test]
15523    fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() {
15524        let package = InstalledPackage {
15525            name: "tessl".to_owned(),
15526            local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15527            installed_version: "0.77.0".to_owned(),
15528            publisher: Some("tessl.io".to_owned()),
15529            scope: Some("User".to_owned()),
15530            installer_category: Some("portable".to_owned()),
15531            install_location: None,
15532            package_family_names: Vec::new(),
15533            product_codes: Vec::new(),
15534            upgrade_codes: Vec::new(),
15535            correlated: None,
15536            winget_package_identifier: Some("tessl.tessl".to_owned()),
15537            winget_source_identifier: Some("api.winget.pro".to_owned()),
15538            installed_version_canonical: false,
15539            correlated_requires_explicit_upgrade: false,
15540            correlated_lacks_compatible_installer: false,
15541        };
15542        let sources = vec![SourceRecord {
15543            name: "winget.pro".to_owned(),
15544            kind: SourceKind::Rest,
15545            arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(),
15546            identifier: "api.winget.pro".to_owned(),
15547            trust_level: "Trusted".to_owned(),
15548            explicit: false,
15549            priority: 0,
15550            last_update: None,
15551            source_version: None,
15552            etag: None,
15553            last_modified: None,
15554        }];
15555
15556        let item = list_match_from_installed(package, &sources);
15557
15558        assert_eq!(item.id, "tessl.tessl");
15559        assert_eq!(item.source_name.as_deref(), Some("winget.pro"));
15560    }
15561
15562    #[test]
15563    fn list_match_from_installed_keeps_scanning_portable_source_identifiers() {
15564        let package = InstalledPackage {
15565            name: "Example".to_owned(),
15566            local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(),
15567            installed_version: "1.0".to_owned(),
15568            publisher: Some("Vendor".to_owned()),
15569            scope: Some("User".to_owned()),
15570            installer_category: Some("portable".to_owned()),
15571            install_location: None,
15572            package_family_names: Vec::new(),
15573            product_codes: Vec::new(),
15574            upgrade_codes: Vec::new(),
15575            correlated: None,
15576            winget_package_identifier: None,
15577            winget_source_identifier: None,
15578            installed_version_canonical: false,
15579            correlated_requires_explicit_upgrade: false,
15580            correlated_lacks_compatible_installer: false,
15581        };
15582        let sources = vec![
15583            SourceRecord {
15584                name: "long".to_owned(),
15585                kind: SourceKind::PreIndexed,
15586                arg: "https://example.invalid/long".to_owned(),
15587                identifier: "identifier-longer-than-local-id".to_owned(),
15588                trust_level: "Trusted".to_owned(),
15589                explicit: false,
15590                priority: 0,
15591                last_update: None,
15592                source_version: None,
15593                etag: None,
15594                last_modified: None,
15595            },
15596            SourceRecord {
15597                name: "short".to_owned(),
15598                kind: SourceKind::PreIndexed,
15599                arg: "https://example.invalid/short".to_owned(),
15600                identifier: "short".to_owned(),
15601                trust_level: "Trusted".to_owned(),
15602                explicit: false,
15603                priority: 1,
15604                last_update: None,
15605                source_version: None,
15606                etag: None,
15607                last_modified: None,
15608            },
15609        ];
15610
15611        let item = list_match_from_installed(package, &sources);
15612
15613        assert_eq!(item.id, "Vendor.Example");
15614        assert_eq!(item.source_name.as_deref(), Some("short"));
15615    }
15616
15617    #[test]
15618    fn list_response_serialization_keeps_pascal_case_fields() {
15619        let response = ListResponse {
15620            matches: vec![ListMatch {
15621                name: "PowerToys".to_owned(),
15622                id: "Microsoft.PowerToys".to_owned(),
15623                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
15624                installed_version: "0.98.1".to_owned(),
15625                available_version: Some("0.99.0".to_owned()),
15626                source_name: Some("winget".to_owned()),
15627                publisher: Some("Microsoft".to_owned()),
15628                scope: Some("Machine".to_owned()),
15629                installer_category: Some("msi".to_owned()),
15630                install_location: None,
15631                package_family_names: Vec::new(),
15632                product_codes: Vec::new(),
15633                upgrade_codes: Vec::new(),
15634            }],
15635            warnings: Vec::new(),
15636            truncated: false,
15637        };
15638
15639        let value = serde_json::to_value(&response).expect("serialize list response");
15640        let match_value = &value["Matches"][0];
15641
15642        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
15643        assert_eq!(match_value["InstalledVersion"], "0.98.1");
15644        assert_eq!(match_value["AvailableVersion"], "0.99.0");
15645        assert_eq!(match_value["SourceName"], "winget");
15646        assert!(match_value.get("local_id").is_none());
15647        assert!(match_value.get("installed_version").is_none());
15648        assert!(match_value.get("available_version").is_none());
15649        assert!(match_value.get("source_name").is_none());
15650    }
15651
15652    #[test]
15653    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
15654        let response = SearchResponse {
15655            matches: vec![SearchMatch {
15656                source_name: "msstore".to_owned(),
15657                source_kind: SourceKind::Rest,
15658                id: "9WZDNCRFJBMP".to_owned(),
15659                name: "Microsoft To Do".to_owned(),
15660                moniker: None,
15661                version: Some("2.123.456.0".to_owned()),
15662                channel: None,
15663                match_criteria: None,
15664            }],
15665            warnings: Vec::new(),
15666            truncated: false,
15667        };
15668
15669        let value = serde_json::to_value(&response).expect("serialize search response");
15670        let match_value = &value["Matches"][0];
15671
15672        assert_eq!(match_value["Name"], "Microsoft To Do");
15673        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
15674        assert_eq!(match_value["Version"], "2.123.456.0");
15675        assert_eq!(match_value["SourceName"], "msstore");
15676        assert!(match_value.get("source_name").is_none());
15677    }
15678
15679    #[test]
15680    fn versions_result_serialization_keeps_pascal_case_fields() {
15681        let result = VersionsResult {
15682            package: SearchMatch {
15683                source_name: "winget".to_owned(),
15684                source_kind: SourceKind::PreIndexed,
15685                id: "Microsoft.PowerToys".to_owned(),
15686                name: "PowerToys".to_owned(),
15687                moniker: None,
15688                version: Some("0.99.0".to_owned()),
15689                channel: None,
15690                match_criteria: None,
15691            },
15692            versions: vec![
15693                VersionKey {
15694                    version: "0.99.0".to_owned(),
15695                    channel: "stable".to_owned(),
15696                },
15697                VersionKey {
15698                    version: "0.100.0-preview".to_owned(),
15699                    channel: "preview".to_owned(),
15700                },
15701            ],
15702            warnings: Vec::new(),
15703        };
15704
15705        let value = serde_json::to_value(&result).expect("serialize versions result");
15706
15707        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
15708        assert_eq!(value["Versions"][0]["Channel"], "stable");
15709        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
15710        assert_eq!(value["Versions"][1]["Channel"], "preview");
15711        assert!(value.get("versions").is_none());
15712    }
15713
15714    #[test]
15715    fn version_data_parses_arp_bounds_from_winget_payload() {
15716        // Sanity check that aMiV/aMaV deserialize from a real-world
15717        // versionData.mszyml payload shape. Without `default`, packages
15718        // predating AppsAndFeaturesEntries would fail to parse.
15719        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";
15720
15721        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
15722        assert_eq!(document.versions.len(), 2);
15723        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
15724        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
15725        assert!(document.versions[1].arp_min_version.is_none());
15726        assert!(document.versions[1].arp_max_version.is_none());
15727    }
15728
15729    #[test]
15730    fn msix_resource_string_name_resolves_to_catalog_name() {
15731        // App Installer's MSIX manifest stores DisplayName as
15732        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
15733        // know the catalog calls it `App Installer` — show that instead of
15734        // the unresolved placeholder, matching winget's output.
15735        let mut package = InstalledPackage {
15736            name: "ms-resource:appDisplayName".to_owned(),
15737            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
15738            installed_version: "1.28.239.0".to_owned(),
15739            publisher: None,
15740            scope: Some("User".to_owned()),
15741            installer_category: Some("msix".to_owned()),
15742            install_location: None,
15743            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
15744            product_codes: Vec::new(),
15745            upgrade_codes: Vec::new(),
15746            correlated: Some(SearchMatch {
15747                source_name: "winget".to_owned(),
15748                source_kind: SourceKind::PreIndexed,
15749                id: "Microsoft.AppInstaller".to_owned(),
15750                name: "App Installer".to_owned(),
15751                moniker: None,
15752                version: Some("1.28.240.0".to_owned()),
15753                channel: None,
15754                match_criteria: Some("PackageFamilyName".to_owned()),
15755            }),
15756            winget_package_identifier: None,
15757            winget_source_identifier: None,
15758            installed_version_canonical: false,
15759            correlated_requires_explicit_upgrade: false,
15760            correlated_lacks_compatible_installer: false,
15761        };
15762        apply_msix_resource_string_name_fix(&mut package);
15763        assert_eq!(package.name, "App Installer");
15764    }
15765
15766    #[test]
15767    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
15768        // ARP rows can also carry unusual display names; the fix is gated
15769        // on the local_id prefix so a `ms-resource:` literal that somehow
15770        // appears in an ARP DisplayName (extremely unlikely, but possible)
15771        // doesn't get silently rewritten.
15772        let mut package = InstalledPackage {
15773            name: "ms-resource:appDisplayName".to_owned(),
15774            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
15775            installed_version: "1.0".to_owned(),
15776            publisher: None,
15777            scope: Some("Machine".to_owned()),
15778            installer_category: Some("msi".to_owned()),
15779            install_location: None,
15780            package_family_names: Vec::new(),
15781            product_codes: Vec::new(),
15782            upgrade_codes: Vec::new(),
15783            correlated: Some(SearchMatch {
15784                source_name: "winget".to_owned(),
15785                source_kind: SourceKind::PreIndexed,
15786                id: "Some.Package".to_owned(),
15787                name: "Should Not Apply".to_owned(),
15788                moniker: None,
15789                version: Some("1.0".to_owned()),
15790                channel: None,
15791                match_criteria: None,
15792            }),
15793            winget_package_identifier: None,
15794            winget_source_identifier: None,
15795            installed_version_canonical: false,
15796            correlated_requires_explicit_upgrade: false,
15797            correlated_lacks_compatible_installer: false,
15798        };
15799        apply_msix_resource_string_name_fix(&mut package);
15800        assert_eq!(package.name, "ms-resource:appDisplayName");
15801    }
15802
15803    #[test]
15804    fn msix_resource_string_fix_skips_resolved_names() {
15805        // The vast majority of MSIX entries have already-resolved names
15806        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
15807        // those — `meta.name` and the installed name may legitimately
15808        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
15809        let mut package = InstalledPackage {
15810            name: "Microsoft Teams".to_owned(),
15811            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
15812            installed_version: "25290.205.4069.4894".to_owned(),
15813            publisher: None,
15814            scope: Some("User".to_owned()),
15815            installer_category: Some("msix".to_owned()),
15816            install_location: None,
15817            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
15818            product_codes: Vec::new(),
15819            upgrade_codes: Vec::new(),
15820            correlated: Some(SearchMatch {
15821                source_name: "winget".to_owned(),
15822                source_kind: SourceKind::PreIndexed,
15823                id: "Microsoft.Teams".to_owned(),
15824                name: "Microsoft Teams Catalog Name".to_owned(),
15825                moniker: None,
15826                version: Some("26106.1906.4665.7308".to_owned()),
15827                channel: None,
15828                match_criteria: Some("PackageFamilyName".to_owned()),
15829            }),
15830            winget_package_identifier: None,
15831            winget_source_identifier: None,
15832            installed_version_canonical: false,
15833            correlated_requires_explicit_upgrade: false,
15834            correlated_lacks_compatible_installer: false,
15835        };
15836        apply_msix_resource_string_name_fix(&mut package);
15837        assert_eq!(package.name, "Microsoft Teams");
15838    }
15839
15840    #[cfg(windows)]
15841    #[test]
15842    fn unflip_packed_guid_reverses_msi_installer_packing() {
15843        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
15844        // chunks (8/4/4/2*8). This is the encoding used inside
15845        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
15846        // and their value names. Verified against the user's installed
15847        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
15848        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
15849        assert_eq!(
15850            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
15851            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
15852        );
15853        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
15854        // form `A3A70C74FE2431248AD5F8A59570C782`.
15855        assert_eq!(
15856            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
15857            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
15858        );
15859        // Reject non-32-char or non-hex inputs.
15860        assert!(unflip_packed_guid("nothex").is_none());
15861        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
15862        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
15863    }
15864
15865    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
15866        InstalledPackage {
15867            name: format!("{id} install"),
15868            local_id: format!(r"ARP\Machine\X64\{id}"),
15869            installed_version: installed_version.to_owned(),
15870            publisher: None,
15871            scope: Some("Machine".to_owned()),
15872            installer_category: Some("msi".to_owned()),
15873            install_location: None,
15874            package_family_names: Vec::new(),
15875            product_codes: Vec::new(),
15876            upgrade_codes: Vec::new(),
15877            correlated: Some(SearchMatch {
15878                source_name: "winget".to_owned(),
15879                source_kind: SourceKind::PreIndexed,
15880                id: id.to_owned(),
15881                name: id.to_owned(),
15882                moniker: None,
15883                version: Some("99.0.0".to_owned()),
15884                channel: None,
15885                match_criteria: None,
15886            }),
15887            winget_package_identifier: None,
15888            winget_source_identifier: None,
15889            installed_version_canonical: canonical,
15890            correlated_requires_explicit_upgrade: false,
15891            correlated_lacks_compatible_installer: false,
15892        }
15893    }
15894
15895    #[test]
15896    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
15897        // Two installed entries correlate to the same catalog id. One had its
15898        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
15899        // the other did not (e.g. VS-installed .NET SDK whose ARP
15900        // `40.10.18029` doesn't fit any bucket). Without the canonical
15901        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
15902        // wrong row wins and the upgrade silently disappears.
15903        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
15904        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
15905
15906        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
15907        assert_eq!(result.len(), 1);
15908        assert_eq!(result[0].installed_version, "10.0.108");
15909        assert!(result[0].installed_version_canonical);
15910    }
15911
15912    #[test]
15913    fn dedupe_keeps_highest_among_canonical_rows() {
15914        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
15915        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
15916
15917        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
15918        assert_eq!(result.len(), 1);
15919        assert_eq!(result[0].installed_version, "1.7.9");
15920    }
15921
15922    #[test]
15923    fn dedupe_leaves_uncorrelated_rows_alone() {
15924        // Uncorrelated entries don't reach upgrade output anyway (the
15925        // upgrade filter requires correlation), but the dedupe pass must not
15926        // drop them — `pinget list` could share this code path in the future.
15927        let uncorrelated = InstalledPackage {
15928            name: "Foo".to_owned(),
15929            local_id: r"ARP\Machine\X64\Foo".to_owned(),
15930            installed_version: "1.0".to_owned(),
15931            publisher: None,
15932            scope: Some("Machine".to_owned()),
15933            installer_category: Some("exe".to_owned()),
15934            install_location: None,
15935            package_family_names: Vec::new(),
15936            product_codes: Vec::new(),
15937            upgrade_codes: Vec::new(),
15938            correlated: None,
15939            winget_package_identifier: None,
15940            winget_source_identifier: None,
15941            installed_version_canonical: false,
15942            correlated_requires_explicit_upgrade: false,
15943            correlated_lacks_compatible_installer: false,
15944        };
15945        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
15946        assert_eq!(result.len(), 1);
15947    }
15948}