Skip to main content

pinget_core/
lib.rs

1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashSet};
5use std::fmt::{Display, Formatter};
6use std::fs;
7use std::io::{Cursor, Read};
8#[cfg(windows)]
9use std::mem::{MaybeUninit, size_of};
10#[cfg(windows)]
11use std::os::windows::ffi::OsStringExt;
12use std::path::{Path, PathBuf};
13use std::sync::RwLock;
14
15use anyhow::{Context, Result, anyhow, bail};
16use chrono::{DateTime, Duration, Utc};
17use reqwest::blocking::{Client, Response};
18use rusqlite::types::{Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, Row as SqlRow, params_from_iter};
20use serde::{Deserialize, Serialize};
21use serde_json::{Map as JsonMap, Value as JsonValue};
22use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
23use sha2::{Digest, Sha256};
24#[cfg(windows)]
25use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
26#[cfg(windows)]
27use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
28#[cfg(windows)]
29use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
30#[cfg(windows)]
31use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
32#[cfg(windows)]
33use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
34#[cfg(windows)]
35use winreg::{RegKey, enums::*};
36use zip::ZipArchive;
37
38const DEFAULT_MARKET: &str = "US";
39const DEFAULT_MAX_RESULTS: usize = 50;
40const LIST_LOOKUP_MAX_RESULTS: usize = 500;
41const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
42const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
43#[cfg(windows)]
44const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
45#[cfg(windows)]
46const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
47const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
48    "Installed package discovery is not supported on this platform; returning no installed packages.";
49const INSTALL_UNSUPPORTED_WARNING: &str =
50    "Installing packages is not supported on this platform; no changes were made.";
51const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
52const REPAIR_REINSTALL_WARNING: &str =
53    "Pinget repair currently re-runs the package install flow for the selected package.";
54const UNINSTALL_UNSUPPORTED_WARNING: &str =
55    "Uninstalling packages is not supported on this platform; no changes were made.";
56#[cfg(windows)]
57const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
58const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
59    "LocalManifestFiles",
60    "BypassCertificatePinningForMicrosoftStore",
61    "InstallerHashOverride",
62    "LocalArchiveMalwareScanOverride",
63    "ProxyCommandLineOptions",
64];
65const REST_SUPPORTED_CONTRACTS: &[&str] = &[
66    "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",
67];
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70pub enum SourceKind {
71    #[serde(rename = "PreIndexed", alias = "preIndexed")]
72    PreIndexed,
73    #[serde(rename = "Rest", alias = "rest")]
74    Rest,
75}
76
77impl Display for SourceKind {
78    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
79        match self {
80            SourceKind::PreIndexed => f.write_str("preindexed"),
81            SourceKind::Rest => f.write_str("rest"),
82        }
83    }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct SourceRecord {
88    pub name: String,
89    pub kind: SourceKind,
90    pub arg: String,
91    pub identifier: String,
92    #[serde(default = "default_source_trust_level")]
93    pub trust_level: String,
94    #[serde(default)]
95    pub explicit: bool,
96    #[serde(default)]
97    pub priority: i32,
98    #[serde(default)]
99    pub last_update: Option<DateTime<Utc>>,
100    #[serde(default)]
101    pub source_version: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105struct SourceStore {
106    sources: Vec<SourceRecord>,
107}
108
109impl Default for SourceStore {
110    fn default() -> Self {
111        Self {
112            sources: vec![
113                SourceRecord {
114                    name: "winget".to_owned(),
115                    kind: SourceKind::PreIndexed,
116                    arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
117                    identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
118                    trust_level: "Trusted".to_owned(),
119                    explicit: false,
120                    priority: 0,
121                    last_update: None,
122                    source_version: None,
123                },
124                SourceRecord {
125                    name: "msstore".to_owned(),
126                    kind: SourceKind::Rest,
127                    arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
128                    identifier: "StoreEdgeFD".to_owned(),
129                    trust_level: "Trusted".to_owned(),
130                    explicit: false,
131                    priority: 0,
132                    last_update: None,
133                    source_version: None,
134                },
135            ],
136        }
137    }
138}
139
140/// Options for embedding the WinGet core library in another application.
141#[derive(Debug, Clone)]
142pub struct RepositoryOptions {
143    pub app_root: PathBuf,
144    pub user_agent: String,
145}
146
147impl RepositoryOptions {
148    /// Creates library options that keep all persistent state under the supplied root.
149    pub fn new(app_root: impl Into<PathBuf>) -> Self {
150        Self {
151            app_root: app_root.into(),
152            user_agent: DEFAULT_USER_AGENT.to_owned(),
153        }
154    }
155
156    /// Uses the default per-user app-data root that the CLI also uses.
157    pub fn for_current_user() -> Result<Self> {
158        Ok(Self::new(default_app_root()?))
159    }
160
161    /// Overrides the HTTP user-agent sent to source endpoints.
162    #[must_use]
163    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
164        self.user_agent = user_agent.into();
165        self
166    }
167}
168
169#[derive(Debug, Clone, Default)]
170pub struct PackageQuery {
171    pub query: Option<String>,
172    pub id: Option<String>,
173    pub name: Option<String>,
174    pub moniker: Option<String>,
175    pub tag: Option<String>,
176    pub command: Option<String>,
177    pub source: Option<String>,
178    pub count: Option<usize>,
179    pub exact: bool,
180    pub version: Option<String>,
181    pub channel: Option<String>,
182    pub locale: Option<String>,
183    pub installer_type: Option<String>,
184    pub installer_architecture: Option<String>,
185    pub platform: Option<String>,
186    pub os_version: Option<String>,
187    pub install_scope: Option<String>,
188}
189
190#[derive(Debug, Clone, Default)]
191pub struct ListQuery {
192    pub query: Option<String>,
193    pub id: Option<String>,
194    pub name: Option<String>,
195    pub moniker: Option<String>,
196    pub tag: Option<String>,
197    pub command: Option<String>,
198    pub product_code: Option<String>,
199    pub version: Option<String>,
200    pub source: Option<String>,
201    pub count: Option<usize>,
202    pub exact: bool,
203    pub install_scope: Option<String>,
204    pub upgrade_only: bool,
205    pub include_unknown: bool,
206    pub include_pinned: bool,
207}
208
209#[derive(Debug, Clone, serde::Serialize)]
210#[serde(rename_all = "PascalCase")]
211pub struct SearchMatch {
212    pub source_name: String,
213    pub source_kind: SourceKind,
214    pub id: String,
215    pub name: String,
216    pub moniker: Option<String>,
217    pub version: Option<String>,
218    pub channel: Option<String>,
219    pub match_criteria: Option<String>,
220}
221
222#[derive(Debug, Clone, serde::Serialize)]
223#[serde(rename_all = "PascalCase")]
224pub struct SearchResponse {
225    pub matches: Vec<SearchMatch>,
226    pub warnings: Vec<String>,
227    pub truncated: bool,
228}
229
230#[derive(Debug, Clone, serde::Serialize)]
231#[serde(rename_all = "PascalCase")]
232pub struct ListMatch {
233    pub name: String,
234    pub id: String,
235    pub local_id: String,
236    pub installed_version: String,
237    pub available_version: Option<String>,
238    pub source_name: Option<String>,
239    pub publisher: Option<String>,
240    pub scope: Option<String>,
241    pub installer_category: Option<String>,
242    pub install_location: Option<String>,
243    pub package_family_names: Vec<String>,
244    pub product_codes: Vec<String>,
245    pub upgrade_codes: Vec<String>,
246}
247
248#[derive(Debug, Clone, serde::Serialize)]
249#[serde(rename_all = "PascalCase")]
250pub struct ListResponse {
251    pub matches: Vec<ListMatch>,
252    pub warnings: Vec<String>,
253    pub truncated: bool,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
257#[serde(rename_all = "PascalCase")]
258pub struct VersionKey {
259    pub version: String,
260    pub channel: String,
261}
262
263#[derive(Debug, Clone, serde::Serialize)]
264pub struct Manifest {
265    pub id: String,
266    pub name: String,
267    pub version: String,
268    pub channel: String,
269    pub publisher: Option<String>,
270    pub description: Option<String>,
271    pub moniker: Option<String>,
272    pub package_url: Option<String>,
273    pub publisher_url: Option<String>,
274    pub publisher_support_url: Option<String>,
275    pub license: Option<String>,
276    pub license_url: Option<String>,
277    pub privacy_url: Option<String>,
278    pub author: Option<String>,
279    pub copyright: Option<String>,
280    pub copyright_url: Option<String>,
281    pub release_notes: Option<String>,
282    pub release_notes_url: Option<String>,
283    pub tags: Vec<String>,
284    pub agreements: Vec<PackageAgreement>,
285    pub package_dependencies: Vec<String>,
286    pub documentation: Vec<Documentation>,
287    pub installers: Vec<Installer>,
288    // `RequireExplicitUpgrade: true` opts a package out of bulk
289    // `pinget upgrade` output (winget parity). Users can still upgrade by
290    // explicit `id`. Set at top-level or per-installer; treated as true
291    // when any installer asserts it.
292    pub require_explicit_upgrade: bool,
293}
294
295#[derive(Debug, Clone, serde::Serialize)]
296pub struct Documentation {
297    pub label: Option<String>,
298    pub url: String,
299}
300
301#[derive(Debug, Clone, serde::Serialize)]
302pub struct PackageAgreement {
303    pub label: Option<String>,
304    pub text: Option<String>,
305    pub url: Option<String>,
306}
307
308#[derive(Debug, Clone, Default, serde::Serialize)]
309pub struct Installer {
310    pub architecture: Option<String>,
311    pub installer_type: Option<String>,
312    pub nested_installer_type: Option<String>,
313    pub nested_installer_files: Vec<NestedInstallerFile>,
314    pub url: Option<String>,
315    pub sha256: Option<String>,
316    pub product_code: Option<String>,
317    pub locale: Option<String>,
318    pub scope: Option<String>,
319    pub release_date: Option<String>,
320    pub package_family_name: Option<String>,
321    pub upgrade_code: Option<String>,
322    pub platforms: Vec<String>,
323    pub minimum_os_version: Option<String>,
324    pub switches: InstallerSwitches,
325    pub commands: Vec<String>,
326    pub package_dependencies: Vec<String>,
327    pub require_explicit_upgrade: bool,
328}
329
330#[derive(Debug, Clone, Default, serde::Serialize)]
331pub struct NestedInstallerFile {
332    /// Path to the executable within the extracted archive.
333    pub relative_file_path: String,
334    /// Optional alias winget exposes for portable invocation. Pinget records it
335    /// for parity and uses it during portable installation to create a
336    /// `Links\\<alias>.exe` symlink; `Links\\` may also be added to the user PATH.
337    pub portable_command_alias: Option<String>,
338}
339
340#[derive(Debug, Clone, Default, serde::Serialize)]
341pub struct InstallerSwitches {
342    pub silent: Option<String>,
343    pub silent_with_progress: Option<String>,
344    pub interactive: Option<String>,
345    pub custom: Option<String>,
346    pub log: Option<String>,
347    pub install_location: Option<String>,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum InstallerMode {
352    Interactive,
353    SilentWithProgress,
354    Silent,
355}
356
357#[derive(Debug, Clone)]
358pub struct InstallRequest {
359    pub query: PackageQuery,
360    pub manifest_path: Option<PathBuf>,
361    pub mode: InstallerMode,
362    pub log_path: Option<PathBuf>,
363    pub custom: Option<String>,
364    pub override_args: Option<String>,
365    pub install_location: Option<String>,
366    pub skip_dependencies: bool,
367    pub dependencies_only: bool,
368    pub accept_package_agreements: bool,
369    pub force: bool,
370    pub rename: Option<String>,
371    pub uninstall_previous: bool,
372    pub ignore_security_hash: bool,
373    pub dependency_source: Option<String>,
374    pub no_upgrade: bool,
375}
376
377impl InstallRequest {
378    pub fn new(query: PackageQuery) -> Self {
379        Self {
380            query,
381            manifest_path: None,
382            mode: InstallerMode::SilentWithProgress,
383            log_path: None,
384            custom: None,
385            override_args: None,
386            install_location: None,
387            skip_dependencies: false,
388            dependencies_only: false,
389            accept_package_agreements: false,
390            force: false,
391            rename: None,
392            uninstall_previous: false,
393            ignore_security_hash: false,
394            dependency_source: None,
395            no_upgrade: false,
396        }
397    }
398}
399
400#[derive(Debug, Clone)]
401pub struct UninstallRequest {
402    pub query: PackageQuery,
403    pub manifest_path: Option<PathBuf>,
404    pub product_code: Option<String>,
405    pub mode: InstallerMode,
406    pub all_versions: bool,
407    pub force: bool,
408    pub purge: bool,
409    pub preserve: bool,
410    pub log_path: Option<PathBuf>,
411}
412
413impl UninstallRequest {
414    pub fn new(query: PackageQuery) -> Self {
415        Self {
416            query,
417            manifest_path: None,
418            product_code: None,
419            mode: InstallerMode::SilentWithProgress,
420            all_versions: false,
421            force: false,
422            purge: false,
423            preserve: false,
424            log_path: None,
425        }
426    }
427}
428
429#[derive(Debug, Clone)]
430pub struct RepairRequest {
431    pub query: PackageQuery,
432    pub manifest_path: Option<PathBuf>,
433    pub product_code: Option<String>,
434    pub mode: InstallerMode,
435    pub log_path: Option<PathBuf>,
436    pub accept_package_agreements: bool,
437    pub force: bool,
438    pub ignore_security_hash: bool,
439}
440
441impl RepairRequest {
442    pub fn new(query: PackageQuery) -> Self {
443        Self {
444            query,
445            manifest_path: None,
446            product_code: None,
447            mode: InstallerMode::SilentWithProgress,
448            log_path: None,
449            accept_package_agreements: false,
450            force: false,
451            ignore_security_hash: false,
452        }
453    }
454}
455
456#[derive(Debug, Clone, serde::Serialize)]
457pub struct ShowResult {
458    pub package: SearchMatch,
459    pub manifest: Manifest,
460    pub selected_installer: Option<Installer>,
461    pub cached_files: Vec<PathBuf>,
462    pub warnings: Vec<String>,
463    #[serde(skip_serializing)]
464    manifest_documents: JsonValue,
465}
466
467impl ShowResult {
468    pub fn structured_document(&self) -> JsonValue {
469        collapse_structured_document(&self.manifest_documents)
470    }
471}
472
473#[derive(Debug, Clone, serde::Serialize)]
474#[serde(rename_all = "PascalCase")]
475pub struct VersionsResult {
476    pub package: SearchMatch,
477    pub versions: Vec<VersionKey>,
478    pub warnings: Vec<String>,
479}
480
481#[derive(Debug, Clone, serde::Serialize)]
482#[serde(rename_all = "PascalCase")]
483pub struct CacheWarmResult {
484    pub package: SearchMatch,
485    pub cached_files: Vec<PathBuf>,
486    pub warnings: Vec<String>,
487}
488
489#[derive(Debug, Clone, serde::Serialize)]
490#[serde(rename_all = "PascalCase")]
491pub struct SourceUpdateResult {
492    pub name: String,
493    pub kind: SourceKind,
494    pub detail: String,
495}
496
497#[derive(Debug, Clone, serde::Serialize)]
498#[serde(rename_all = "PascalCase")]
499pub struct PinRecord {
500    pub package_id: String,
501    pub version: String,
502    pub source_id: String,
503    pub pin_type: PinType,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
507pub enum PinType {
508    Pinning,
509    Blocking,
510    Gating,
511}
512
513impl Display for PinType {
514    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
515        match self {
516            PinType::Pinning => f.write_str("Pinning"),
517            PinType::Blocking => f.write_str("Blocking"),
518            PinType::Gating => f.write_str("Gating"),
519        }
520    }
521}
522
523/// Result of an install/uninstall/upgrade operation
524#[derive(Debug, Clone, serde::Serialize)]
525pub struct InstallResult {
526    pub package_id: String,
527    pub version: String,
528    pub installer_path: PathBuf,
529    pub installer_type: String,
530    pub exit_code: i32,
531    pub success: bool,
532    pub no_op: bool,
533    pub warnings: Vec<String>,
534}
535
536impl InstallerSwitches {
537    fn with_fallback(&self, fallback: &Self) -> Self {
538        Self {
539            silent: self.silent.clone().or_else(|| fallback.silent.clone()),
540            silent_with_progress: self
541                .silent_with_progress
542                .clone()
543                .or_else(|| fallback.silent_with_progress.clone()),
544            interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
545            custom: self.custom.clone().or_else(|| fallback.custom.clone()),
546            log: self.log.clone().or_else(|| fallback.log.clone()),
547            install_location: self
548                .install_location
549                .clone()
550                .or_else(|| fallback.install_location.clone()),
551        }
552    }
553}
554
555fn installed_package_discovery_supported() -> bool {
556    cfg!(windows)
557}
558
559fn package_actions_supported() -> bool {
560    cfg!(windows)
561}
562
563fn unsupported_action_result(
564    package_id: impl Into<String>,
565    version: impl Into<String>,
566    installer_type: impl Into<String>,
567    warning: &str,
568) -> InstallResult {
569    InstallResult {
570        package_id: package_id.into(),
571        version: version.into(),
572        installer_path: PathBuf::new(),
573        installer_type: installer_type.into(),
574        exit_code: 0,
575        success: true,
576        no_op: true,
577        warnings: vec![warning.to_owned()],
578    }
579}
580
581#[derive(Debug, Clone)]
582struct InstalledPackage {
583    name: String,
584    local_id: String,
585    installed_version: String,
586    publisher: Option<String>,
587    scope: Option<String>,
588    installer_category: Option<String>,
589    install_location: Option<String>,
590    package_family_names: Vec<String>,
591    product_codes: Vec<String>,
592    upgrade_codes: Vec<String>,
593    correlated: Option<SearchMatch>,
594    // True when `installed_version` was rewritten to a catalog Version via
595    // versionData.mszyml's aMiV/aMaV range. Such versions compare meaningfully
596    // against the catalog's Version; raw ARP DisplayVersions usually don't.
597    // Used by dedupe so that, when the same id has both a canonical and a raw
598    // install row, we prefer the canonical (e.g. `Microsoft.DotNet.SDK.10`
599    // 10.0.108 over the VS-installed 40.10.18029 that doesn't map).
600    installed_version_canonical: bool,
601    // True when the correlated catalog package's latest version sets
602    // `RequireExplicitUpgrade: true`. winget hides those rows from bulk
603    // `upgrade`; we mirror that. Users can still upgrade by explicit id.
604    correlated_requires_explicit_upgrade: bool,
605    // True when the correlated catalog package's latest version has no
606    // installer for an architecture the user can actually run. winget
607    // hides those from `upgrade`; we mirror that.
608    correlated_lacks_compatible_installer: bool,
609}
610
611#[derive(Debug, Clone)]
612struct LocatedMatch {
613    display: SearchMatch,
614    source_index: usize,
615    locator: MatchLocator,
616}
617
618#[derive(Debug)]
619struct SearchSourceMatches {
620    matches: Vec<LocatedMatch>,
621    truncated: bool,
622}
623
624#[derive(Debug, Clone)]
625enum MatchLocator {
626    PreIndexedV1 {
627        package_rowid: i64,
628    },
629    PreIndexedV2 {
630        package_rowid: i64,
631        package_hash: String,
632    },
633    Rest {
634        package_id: String,
635        versions: Vec<VersionKey>,
636    },
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640enum SearchSemantics {
641    Many,
642    Single,
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
646struct RestInfoCache {
647    expires_at: DateTime<Utc>,
648    value: RestInformation,
649}
650
651#[derive(Debug, Clone, Default, Serialize, Deserialize)]
652struct RestInformation {
653    #[serde(rename = "SourceIdentifier")]
654    source_identifier: String,
655    #[serde(rename = "ServerSupportedVersions", default)]
656    server_supported_versions: Vec<String>,
657    #[serde(rename = "RequiredPackageMatchFields", default)]
658    required_package_match_fields: Vec<String>,
659    #[serde(rename = "UnsupportedPackageMatchFields", default)]
660    unsupported_package_match_fields: Vec<String>,
661    #[serde(rename = "RequiredQueryParameters", default)]
662    required_query_parameters: Vec<String>,
663    #[serde(rename = "UnsupportedQueryParameters", default)]
664    unsupported_query_parameters: Vec<String>,
665}
666
667#[derive(Debug, Clone, Deserialize)]
668struct PackageVersionDataDocument {
669    #[serde(rename = "vD", default)]
670    versions: Vec<PackageVersionDataEntry>,
671}
672
673#[derive(Debug, Clone, Deserialize)]
674struct PackageVersionDataEntry {
675    #[serde(rename = "v")]
676    version: String,
677    #[serde(rename = "rP")]
678    manifest_relative_path: String,
679    #[serde(rename = "s256H")]
680    manifest_hash: String,
681    // ARP version range covered by this catalog version. WinGet writes these
682    // into versionData.mszyml so an installed ARP DisplayVersion (which may be
683    // an MSI build number with no relation to the catalog Version) can be
684    // mapped back to the catalog Version it corresponds to.
685    #[serde(rename = "aMiV", default)]
686    arp_min_version: Option<String>,
687    #[serde(rename = "aMaV", default)]
688    arp_max_version: Option<String>,
689}
690
691pub struct Repository {
692    app_root: PathBuf,
693    client: Client,
694    store: SourceStore,
695    use_system_winget_sources: bool,
696}
697
698impl Repository {
699    /// Opens the repository with the default per-user CLI storage layout.
700    pub fn open() -> Result<Self> {
701        Self::open_with_options(RepositoryOptions::for_current_user()?)
702    }
703
704    /// Opens the repository with explicit hosting options for library consumers.
705    pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
706        ensure_app_dirs(&options.app_root)?;
707        let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
708        let store = load_store(&options.app_root)?;
709        let client = Client::builder()
710            .user_agent(&options.user_agent)
711            .build()
712            .context("failed to build HTTP client")?;
713        Ok(Self {
714            app_root: options.app_root,
715            client,
716            store,
717            use_system_winget_sources,
718        })
719    }
720
721    pub fn app_root(&self) -> &Path {
722        &self.app_root
723    }
724
725    pub fn list_sources(&self) -> Vec<SourceRecord> {
726        self.store.sources.clone()
727    }
728
729    pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
730        self.add_source_with_metadata(name, arg, kind, None, false, 0)
731    }
732
733    pub fn add_source_with_metadata(
734        &mut self,
735        name: &str,
736        arg: &str,
737        kind: SourceKind,
738        trust_level: Option<&str>,
739        explicit: bool,
740        priority: i32,
741    ) -> Result<()> {
742        if self.use_system_winget_sources {
743            system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
744            self.refresh_system_winget_sources()?;
745            return Ok(());
746        }
747
748        if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
749            bail!("A source with name '{name}' already exists.");
750        }
751        if self.store.sources.iter().any(|s| s.arg == arg) {
752            bail!("A source with argument '{arg}' already exists.");
753        }
754        let identifier = name.to_owned();
755        self.store.sources.push(SourceRecord {
756            name: name.to_owned(),
757            kind,
758            arg: arg.to_owned(),
759            identifier,
760            trust_level: normalize_source_trust_level(trust_level)?,
761            explicit,
762            priority,
763            last_update: None,
764            source_version: None,
765        });
766        self.save_store()?;
767        Ok(())
768    }
769
770    pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
771        if self.use_system_winget_sources {
772            bail!("Editing system WinGet sources is not supported by winget source commands.");
773        }
774
775        let source = self
776            .store
777            .sources
778            .iter_mut()
779            .find(|source| source.name.eq_ignore_ascii_case(name))
780            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
781
782        if let Some(explicit) = explicit {
783            source.explicit = explicit;
784        }
785
786        if trust_level.is_some() {
787            source.trust_level = normalize_source_trust_level(trust_level)?;
788        }
789
790        self.save_store()?;
791        Ok(())
792    }
793
794    pub fn remove_source(&mut self, name: &str) -> Result<()> {
795        if self.use_system_winget_sources {
796            system_winget_remove_source(name)?;
797            self.refresh_system_winget_sources()?;
798            return Ok(());
799        }
800
801        let idx = self
802            .store
803            .sources
804            .iter()
805            .position(|s| s.name.eq_ignore_ascii_case(name))
806            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
807        let source = self.store.sources.remove(idx);
808        // Clean up cached state for this source
809        let state_dir = self.source_state_dir(&source);
810        if state_dir.exists() {
811            let _ = fs::remove_dir_all(&state_dir);
812        }
813        self.save_store()?;
814        Ok(())
815    }
816
817    pub fn reset_source(&mut self, name: &str) -> Result<()> {
818        if self.use_system_winget_sources {
819            system_winget_reset_source(name)?;
820            self.refresh_system_winget_sources()?;
821            return Ok(());
822        }
823
824        let idx = self
825            .store
826            .sources
827            .iter()
828            .position(|source| source.name.eq_ignore_ascii_case(name))
829            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
830
831        let source = self.store.sources[idx].clone();
832        let state_dir = self.source_state_dir(&source);
833        if state_dir.exists() {
834            let _ = fs::remove_dir_all(&state_dir);
835        }
836
837        if let Some(default_source) = SourceStore::default()
838            .sources
839            .into_iter()
840            .find(|source| source.name.eq_ignore_ascii_case(name))
841        {
842            self.store.sources[idx] = default_source;
843        } else {
844            self.store.sources[idx].last_update = None;
845            self.store.sources[idx].source_version = None;
846        }
847
848        self.save_store()?;
849        Ok(())
850    }
851
852    pub fn reset_sources(&mut self) -> Result<()> {
853        if self.use_system_winget_sources {
854            system_winget_reset_sources()?;
855            self.refresh_system_winget_sources()?;
856            return Ok(());
857        }
858
859        // Remove cached state for all current sources
860        for source in &self.store.sources {
861            let state_dir = self.source_state_dir(source);
862            if state_dir.exists() {
863                let _ = fs::remove_dir_all(&state_dir);
864            }
865        }
866        self.store = SourceStore::default();
867        self.save_store()?;
868        Ok(())
869    }
870
871    pub fn supported_admin_settings() -> &'static [&'static str] {
872        SUPPORTED_ADMIN_SETTINGS
873    }
874
875    pub fn get_user_settings(&self) -> Result<JsonValue> {
876        Ok(JsonValue::Object(load_json_object(&user_settings_path(
877            &self.app_root,
878        ))?))
879    }
880
881    pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
882        let update = match user_settings {
883            JsonValue::Object(object) => object.clone(),
884            _ => bail!("user settings must be a JSON object"),
885        };
886
887        let effective = if merge {
888            merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
889        } else {
890            update
891        };
892        save_json_object(user_settings_path(&self.app_root), &effective)?;
893        Ok(JsonValue::Object(effective))
894    }
895
896    pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
897        let current = self.get_user_settings()?;
898        Ok(if ignore_not_set {
899            json_contains(&current, expected)
900        } else {
901            current == *expected
902        })
903    }
904
905    pub fn get_admin_settings(&self) -> Result<JsonValue> {
906        let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
907        for name in SUPPORTED_ADMIN_SETTINGS {
908            settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
909        }
910
911        Ok(JsonValue::Object(settings))
912    }
913
914    pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
915        let normalized = normalize_admin_setting_name(name)?;
916        let mut settings = match self.get_admin_settings()? {
917            JsonValue::Object(settings) => settings,
918            _ => JsonMap::new(),
919        };
920        settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
921        save_json_object(admin_settings_path(&self.app_root), &settings)
922    }
923
924    pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
925        if !reset_all && name.is_none() {
926            bail!("resetting admin settings requires a setting name or reset-all");
927        }
928
929        let mut settings = match self.get_admin_settings()? {
930            JsonValue::Object(settings) => settings,
931            _ => JsonMap::new(),
932        };
933
934        if reset_all {
935            for setting_name in SUPPORTED_ADMIN_SETTINGS {
936                settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
937            }
938        } else {
939            settings.insert(
940                normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
941                JsonValue::Bool(false),
942            );
943        }
944
945        save_json_object(admin_settings_path(&self.app_root), &settings)
946    }
947
948    pub fn ensure_settings_files(&self) -> Result<()> {
949        let user_settings = user_settings_path(&self.app_root);
950        if !user_settings.exists() {
951            save_json_object(user_settings, &JsonMap::new())?;
952        }
953
954        let admin_settings = admin_settings_path(&self.app_root);
955        if !admin_settings.exists() {
956            let mut values = JsonMap::new();
957            for name in SUPPORTED_ADMIN_SETTINGS {
958                values.insert((*name).to_owned(), JsonValue::Bool(false));
959            }
960            save_json_object(admin_settings, &values)?;
961        }
962
963        Ok(())
964    }
965
966    pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
967        if self.use_system_winget_sources {
968            return self.update_system_winget_sources(source_name);
969        }
970
971        let indexes = self.resolve_source_indexes(source_name)?;
972        let mut results = Vec::new();
973
974        for index in indexes {
975            let detail = match self.store.sources[index].kind {
976                SourceKind::PreIndexed => self.update_preindexed(index)?,
977                SourceKind::Rest => self.update_rest(index)?,
978            };
979
980            results.push(SourceUpdateResult {
981                name: self.store.sources[index].name.clone(),
982                kind: self.store.sources[index].kind,
983                detail,
984            });
985        }
986
987        self.save_store()?;
988        Ok(results)
989    }
990
991    pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
992        let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
993        Ok(SearchResponse {
994            matches: matches.into_iter().map(|item| item.display).collect(),
995            warnings,
996            truncated,
997        })
998    }
999
1000    pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1001        let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
1002        let mut structured_documents = Vec::with_capacity(matches.len());
1003        for located in matches {
1004            let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1005            structured_documents.push(manifest_documents);
1006        }
1007
1008        Ok(collapse_structured_documents(&structured_documents))
1009    }
1010
1011    pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1012        if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1013            bail!("--include-unknown and --include-pinned require --upgrade-available");
1014        }
1015        if query.source.is_some()
1016            && query.query.is_none()
1017            && query.id.is_none()
1018            && query.name.is_none()
1019            && query.moniker.is_none()
1020            && query.tag.is_none()
1021            && query.command.is_none()
1022        {
1023            bail!("list --source currently requires a query or explicit filter");
1024        }
1025
1026        let has_filter = list_query_needs_available_lookup(query);
1027        // Plain `list` should still correlate installed packages back to their
1028        // catalog identities so callers see canonical package ids/source names,
1029        // matching winget and the fixed C# Pinget behavior. `upgrade_only`
1030        // still adds the available-version specific filtering on top.
1031        let needs_available = true;
1032
1033        let mut warnings = Vec::new();
1034        if !installed_package_discovery_supported() {
1035            warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1036        }
1037        let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1038
1039        if needs_available {
1040            // Authoritative correlation via the v2 index's identity tables
1041            // (PackageFamilyName / ProductCode / UpgradeCode). This is winget's
1042            // primary path and resolves cases where display-name matching is
1043            // ambiguous (Microsoft.Teams vs Microsoft.Teams.Free) or impossible
1044            // (MSIX with `ms-resource:` placeholder names).
1045            warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1046
1047            // ARP entries that lack identity keys (no PFN/PC/UC) still match
1048            // winget's ARP correlation when their (DisplayName, Publisher),
1049            // run through the NameNormalizer, lands on a single package in
1050            // norm_names2 ∩ norm_publishers2. Covers Inno Setup-style
1051            // installers, MSIs whose ARP keys don't include ProductCode, and
1052            // any vendor that publishes a DisplayName that differs from the
1053            // catalog's PackageName but matches an AppsAndFeaturesEntries
1054            // DisplayName.
1055            warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1056        }
1057
1058        if needs_available && has_filter {
1059            // Filtered lookup: search sources with the user's query for any
1060            // installed package not already resolved by identity.
1061            let available_query = package_query_from_list_query(query);
1062            let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
1063            warnings.extend(source_warnings);
1064            let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1065            for package in &mut installed {
1066                if package.correlated.is_some() {
1067                    continue;
1068                }
1069                package.correlated =
1070                    correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1071            }
1072        } else if needs_available {
1073            // Unfiltered upgrade: look up each installed package by its correlation names
1074            warnings.extend(self.correlate_all_installed(&mut installed)?);
1075        }
1076
1077        if needs_available {
1078            // Enrich rows that correlated via the name-based fallback. Those
1079            // rows skipped the v2-index aMiV/aMaV pass that runs inside
1080            // `correlate_installed_via_index`, so they still carry the raw
1081            // ARP DisplayVersion (e.g. `JetBrains Rider 2025.3.0.1` showing
1082            // build `253.28294.112` instead of the marketing `2025.3.0.1`).
1083            // Also resolves MSIX placeholder names like
1084            // `ms-resource:appDisplayName` to the catalog's display name —
1085            // winget pulls these through Windows.Management.Deployment; the
1086            // catalog name is the parity-faithful substitute pinget has
1087            // available without taking a WinRT dependency.
1088            warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1089        }
1090
1091        // For `upgrade`, collapse multiple installed entries that map to the
1092        // same catalog package id (different ARP rows for side-by-side .NET
1093        // SDKs, several runtime versions of WindowsAppRuntime, the MSIX shim
1094        // alongside an ARP install of Edge, etc.). Keep the entry with the
1095        // highest installed_version so the upgrade comparison runs against
1096        // the user's newest install — matches winget's one-row-per-id output.
1097        if query.upgrade_only {
1098            installed = dedupe_correlated_for_upgrade(installed);
1099        }
1100
1101        let mut matches = installed
1102            .into_iter()
1103            .filter(|package| {
1104                list_package_matches(package, query)
1105                    && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1106            })
1107            .collect::<Vec<_>>();
1108
1109        matches.sort_by(|left, right| {
1110            list_sort_weight(left)
1111                .cmp(&list_sort_weight(right))
1112                .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1113                .then_with(|| left.local_id.cmp(&right.local_id))
1114        });
1115
1116        let pins = if query.upgrade_only {
1117            self.list_pins(query.source.as_deref())?
1118        } else {
1119            Vec::new()
1120        };
1121        let mut list_matches = matches
1122            .into_iter()
1123            .map(list_match_from_installed)
1124            .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1125            .collect::<Vec<_>>();
1126        let truncated = if let Some(limit) = query.count {
1127            let was_truncated = list_matches.len() > limit;
1128            list_matches.truncate(limit);
1129            was_truncated
1130        } else {
1131            false
1132        };
1133
1134        Ok(ListResponse {
1135            matches: list_matches,
1136            warnings,
1137            truncated,
1138        })
1139    }
1140
1141    /// For unfiltered upgrade/list, search the entire available index and correlate
1142    /// against all installed packages. Skips packages already correlated via the
1143    /// v2 identity tables.
1144    fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1145        if installed.iter().all(|p| p.correlated.is_some()) {
1146            return Ok(Vec::new());
1147        }
1148        let all_query = PackageQuery {
1149            query: None,
1150            id: None,
1151            name: None,
1152            moniker: None,
1153            tag: None,
1154            command: None,
1155            source: None,
1156            count: Some(100_000), // fetch the entire index
1157            exact: false,
1158            version: None,
1159            channel: None,
1160            locale: None,
1161            installer_type: None,
1162            installer_architecture: None,
1163            platform: None,
1164            os_version: None,
1165            install_scope: None,
1166        };
1167        let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
1168        let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1169
1170        for package in installed.iter_mut() {
1171            if package.correlated.is_some() {
1172                continue;
1173            }
1174            package.correlated = correlate_installed_package(package, &candidates, true);
1175        }
1176
1177        Ok(warnings)
1178    }
1179
1180    /// Correlates installed packages against the v2 pre-indexed catalog using
1181    /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's
1182    /// authoritative correlation path. Also rewrites `installed_version` to
1183    /// the catalog Version whose ARP range covers the ARP DisplayVersion, so
1184    /// packages whose ARP version is an MSI build number (e.g. .NET SDK,
1185    /// Python) compare against a meaningful catalog version. Packages without
1186    /// any of the three identifiers, or whose identifiers don't match a
1187    /// catalog entry, are left untouched for the name-based fallback.
1188    fn correlate_installed_via_index(
1189        &mut self,
1190        installed: &mut [InstalledPackage],
1191        requested_source: Option<&str>,
1192    ) -> Result<Vec<String>> {
1193        let source_indices: Vec<usize> = self
1194            .store
1195            .sources
1196            .iter()
1197            .enumerate()
1198            .filter(|(_, source)| {
1199                source.kind == SourceKind::PreIndexed
1200                    && match requested_source {
1201                        Some(name) => source.name.eq_ignore_ascii_case(name),
1202                        None => true,
1203                    }
1204            })
1205            .map(|(index, _)| index)
1206            .collect();
1207
1208        for source_index in source_indices {
1209            let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1210                let connection = match self.open_preindexed_connection(source_index) {
1211                    Ok(c) => c,
1212                    Err(_) => continue,
1213                };
1214                if !v2_identity_tables_present(&connection) {
1215                    continue;
1216                }
1217                let mut acc = Vec::new();
1218                for (idx, package) in installed.iter().enumerate() {
1219                    if package.correlated.is_some() {
1220                        continue;
1221                    }
1222                    let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1223                        continue;
1224                    };
1225                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1226                        continue;
1227                    };
1228                    acc.push((idx, meta, by));
1229                }
1230                acc
1231            };
1232
1233            let source = self.source_clone(source_index);
1234            for (idx, meta, by) in resolved {
1235                let (canonical_installed, anchored_latest) =
1236                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1237                        Ok((entries, _)) => (
1238                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1239                            latest_arp_anchored_version(&entries),
1240                        ),
1241                        Err(_) => (None, None),
1242                    };
1243                // When the installed side was rebased to a catalog Version via
1244                // aMiV/aMaV, the available side must compare on the same scale:
1245                // prefer the latest ARP-anchored Version over `latest_version`,
1246                // which can be an internal/MSI build number (e.g. WinAppRuntime
1247                // 1.8's `8000.836.2153.0`) that would manufacture a phantom
1248                // upgrade against an installed canonical like `1.8.6`.
1249                let available_version = match (&canonical_installed, anchored_latest) {
1250                    (Some(_), Some(anchored)) => anchored,
1251                    _ => meta.latest_version,
1252                };
1253                let installed_pkg = &mut installed[idx];
1254                installed_pkg.correlated = Some(SearchMatch {
1255                    source_name: source.name.clone(),
1256                    source_kind: source.kind,
1257                    id: meta.id,
1258                    name: meta.name,
1259                    moniker: meta.moniker,
1260                    version: Some(available_version),
1261                    channel: None,
1262                    match_criteria: Some(by.to_owned()),
1263                });
1264                if let Some(version) = canonical_installed {
1265                    installed_pkg.installed_version = version;
1266                    installed_pkg.installed_version_canonical = true;
1267                }
1268            }
1269        }
1270
1271        Ok(Vec::new())
1272    }
1273
1274    /// Mid-tier correlation that mirrors winget's ARP normalization path.
1275    /// For each installed package without a PFN/PC/UC identity match, the
1276    /// (DisplayName, Publisher) is run through the NameNormalizer and
1277    /// intersected against the v2 index's `norm_names2` and
1278    /// `norm_publishers2` tables. A single resulting `package_rowid`
1279    /// produces a correlation; ambiguous matches are skipped, matching
1280    /// winget's empty-Source behavior when normalization can't pick a
1281    /// winner.
1282    fn correlate_installed_by_normalized_identity(
1283        &mut self,
1284        installed: &mut [InstalledPackage],
1285        requested_source: Option<&str>,
1286    ) -> Result<Vec<String>> {
1287        let source_indices: Vec<usize> = self
1288            .store
1289            .sources
1290            .iter()
1291            .enumerate()
1292            .filter(|(_, source)| {
1293                source.kind == SourceKind::PreIndexed
1294                    && match requested_source {
1295                        Some(name) => source.name.eq_ignore_ascii_case(name),
1296                        None => true,
1297                    }
1298            })
1299            .map(|(index, _)| index)
1300            .collect();
1301
1302        for source_index in source_indices {
1303            let resolved: Vec<(usize, V2PackageMetadata)> = {
1304                let connection = match self.open_preindexed_connection(source_index) {
1305                    Ok(c) => c,
1306                    Err(_) => continue,
1307                };
1308                if !v2_normalized_identity_tables_present(&connection) {
1309                    continue;
1310                }
1311                let mut acc = Vec::new();
1312                for (idx, pkg) in installed.iter().enumerate() {
1313                    if pkg.correlated.is_some() {
1314                        continue;
1315                    }
1316                    // MSIX entries belong to the PFN-only path; skipping
1317                    // here mirrors `correlate_installed_package`'s
1318                    // hard-block on MSIX (their `ms-resource:` placeholder
1319                    // names normalize to garbage anyway).
1320                    if pkg.local_id.starts_with("MSIX\\") {
1321                        continue;
1322                    }
1323                    let Some(publisher) = pkg.publisher.as_deref() else {
1324                        continue;
1325                    };
1326                    let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1327                    if normalized_name.is_empty() {
1328                        continue;
1329                    }
1330                    let normalized_publisher = name_normalization::normalize_publisher(publisher);
1331                    if normalized_publisher.is_empty() {
1332                        continue;
1333                    }
1334                    let Some(rowid) =
1335                        lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1336                    else {
1337                        continue;
1338                    };
1339                    let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1340                        continue;
1341                    };
1342                    acc.push((idx, meta));
1343                }
1344                acc
1345            };
1346
1347            let source = self.source_clone(source_index);
1348            for (idx, meta) in resolved {
1349                let (canonical_installed, anchored_latest) =
1350                    match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1351                        Ok((entries, _)) => (
1352                            map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1353                            latest_arp_anchored_version(&entries),
1354                        ),
1355                        Err(_) => (None, None),
1356                    };
1357                let available_version = match (&canonical_installed, anchored_latest) {
1358                    (Some(_), Some(anchored)) => anchored,
1359                    _ => meta.latest_version,
1360                };
1361                let installed_pkg = &mut installed[idx];
1362                installed_pkg.correlated = Some(SearchMatch {
1363                    source_name: source.name.clone(),
1364                    source_kind: source.kind,
1365                    id: meta.id,
1366                    name: meta.name,
1367                    moniker: meta.moniker,
1368                    version: Some(available_version),
1369                    channel: None,
1370                    match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1371                });
1372                if let Some(version) = canonical_installed {
1373                    installed_pkg.installed_version = version;
1374                    installed_pkg.installed_version_canonical = true;
1375                }
1376            }
1377        }
1378
1379        Ok(Vec::new())
1380    }
1381
1382    /// Runs after both correlation paths to enrich already-correlated rows
1383    /// with information that the identity path applies inline but that
1384    /// name-based correlation skips:
1385    ///   • The MSIX-placeholder name fix — when an MSIX entry's installed
1386    ///     name is an unresolved Windows resource string (`ms-resource:...`)
1387    ///     winget renders the catalog's `PackageName` instead. We do the
1388    ///     same so display-name comparisons match.
1389    ///   • The aMiV/aMaV version remap — `JetBrains.Rider` has no
1390    ///     ProductCode/PFN/UpgradeCode so it correlates by name; without
1391    ///     this pass its installed version stays as the ARP build number
1392    ///     `253.28294.112` instead of the catalog Version `2025.3.0.1`.
1393    fn enrich_correlated_via_index(
1394        &mut self,
1395        installed: &mut [InstalledPackage],
1396        requested_source: Option<&str>,
1397    ) -> Result<Vec<String>> {
1398        // Cheap pass first — no SQL required.
1399        for package in installed.iter_mut() {
1400            apply_msix_resource_string_name_fix(package);
1401        }
1402
1403        // Expensive pass — load versionData for rows whose installed_version
1404        // wasn't remapped earlier.
1405        let source_indices: Vec<usize> = self
1406            .store
1407            .sources
1408            .iter()
1409            .enumerate()
1410            .filter(|(_, source)| {
1411                source.kind == SourceKind::PreIndexed
1412                    && match requested_source {
1413                        Some(name) => source.name.eq_ignore_ascii_case(name),
1414                        None => true,
1415                    }
1416            })
1417            .map(|(index, _)| index)
1418            .collect();
1419
1420        for source_index in source_indices {
1421            let needs: Vec<(usize, V2PackageMetadata)> = {
1422                let connection = match self.open_preindexed_connection(source_index) {
1423                    Ok(c) => c,
1424                    Err(_) => continue,
1425                };
1426                if !v2_identity_tables_present(&connection) {
1427                    continue;
1428                }
1429                let mut acc = Vec::new();
1430                for (idx, pkg) in installed.iter().enumerate() {
1431                    let Some(candidate) = pkg.correlated.as_ref() else {
1432                        continue;
1433                    };
1434                    // Stay within the source the row was correlated to —
1435                    // looking up `JetBrains.Rider` in a different catalog
1436                    // would either miss or pull the wrong package's range.
1437                    if !candidate
1438                        .source_name
1439                        .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1440                    {
1441                        continue;
1442                    }
1443                    let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1444                        continue;
1445                    };
1446                    acc.push((idx, meta));
1447                }
1448                acc
1449            };
1450
1451            let source = self.source_clone(source_index);
1452            for (idx, meta) in needs {
1453                // Load versionData once: we may need it for both the
1454                // aMiV/aMaV remap (only if installed_version isn't already
1455                // canonical) and to locate the latest manifest for the
1456                // RequireExplicitUpgrade flag.
1457                let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1458                    Ok((entries, _)) => entries,
1459                    Err(_) => continue,
1460                };
1461
1462                if !installed[idx].installed_version_canonical {
1463                    let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version);
1464                    let anchored_latest = latest_arp_anchored_version(&entries);
1465                    if let Some(canonical) = canonical_installed {
1466                        installed[idx].installed_version = canonical;
1467                        installed[idx].installed_version_canonical = true;
1468                        if let (Some(correlated), Some(anchored)) =
1469                            (installed[idx].correlated.as_mut(), anchored_latest)
1470                        {
1471                            correlated.version = Some(anchored);
1472                        }
1473                    }
1474                }
1475
1476                // RequireExplicitUpgrade is set on the installer manifest
1477                // for the latest catalog version. winget hides these from
1478                // bulk `upgrade` (Edge, Steam, Discord and others). We
1479                // peek at the first versionData entry (latest by sort
1480                // order) and read its manifest to mirror that filtering.
1481                if let Some(latest) = entries.first() {
1482                    let bytes_result = self.get_cached_source_file(
1483                        "V2_M",
1484                        &source,
1485                        &latest.manifest_relative_path,
1486                        Some(latest.manifest_hash.as_str()),
1487                    );
1488                    if let Ok(bytes) = bytes_result
1489                        && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1490                    {
1491                        installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1492                        installed[idx].correlated_lacks_compatible_installer =
1493                            !manifest_has_compatible_installer(&manifest);
1494                    }
1495                }
1496            }
1497        }
1498
1499        Ok(Vec::new())
1500    }
1501
1502    pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1503        let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1504        let versions = self.versions_for_match(&located, query)?;
1505        Ok(VersionsResult {
1506            package: located.display,
1507            versions,
1508            warnings,
1509        })
1510    }
1511
1512    pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1513        let (located, warnings) = self.find_single_match(query)?;
1514        let versions = self.versions_for_match(&located, query)?;
1515        Ok(VersionsResult {
1516            package: located.display,
1517            versions,
1518            warnings,
1519        })
1520    }
1521
1522    pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1523        let (located, warnings) = self.find_single_match(query)?;
1524        let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1525        let selected_installer = select_installer(&manifest.installers, query);
1526
1527        Ok(ShowResult {
1528            package: located.display,
1529            manifest,
1530            selected_installer,
1531            cached_files,
1532            warnings,
1533            manifest_documents,
1534        })
1535    }
1536
1537    pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1538        let (located, warnings) = self.find_single_match(query)?;
1539        let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1540
1541        Ok(CacheWarmResult {
1542            package: located.display,
1543            cached_files,
1544            warnings,
1545        })
1546    }
1547
1548    // ── Pin management ──
1549
1550    pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
1551        let db_path = pins_db_path(&self.app_root);
1552        if !db_path.exists() {
1553            return Ok(Vec::new());
1554        }
1555        let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
1556        let pin_type_column = match resolve_pin_type_column(&conn)? {
1557            Some(column) => column,
1558            None => return Ok(Vec::new()),
1559        };
1560        let sql = if source_id.is_some() {
1561            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
1562        } else {
1563            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
1564        };
1565        let mut stmt = conn.prepare(&sql)?;
1566        let rows = if let Some(source_id) = source_id {
1567            stmt.query_map([source_id], |row| {
1568                let pin_type_int: i64 = row.get(3)?;
1569                Ok(PinRecord {
1570                    package_id: row.get(0)?,
1571                    version: row.get(1)?,
1572                    source_id: row.get(2)?,
1573                    pin_type: decode_pin_type(pin_type_int),
1574                })
1575            })?
1576            .filter_map(|r| r.ok())
1577            .collect()
1578        } else {
1579            stmt.query_map([], |row| {
1580                let pin_type_int: i64 = row.get(3)?;
1581                Ok(PinRecord {
1582                    package_id: row.get(0)?,
1583                    version: row.get(1)?,
1584                    source_id: row.get(2)?,
1585                    pin_type: decode_pin_type(pin_type_int),
1586                })
1587            })?
1588            .filter_map(|r| r.ok())
1589            .collect()
1590        };
1591        Ok(rows)
1592    }
1593
1594    pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
1595        let db_path = pins_db_path(&self.app_root);
1596        let conn = Connection::open(&db_path)?;
1597        conn.execute_batch(
1598            "CREATE TABLE IF NOT EXISTS pin (
1599                package_id TEXT NOT NULL,
1600                version TEXT NOT NULL DEFAULT '*',
1601                source_id TEXT NOT NULL DEFAULT '',
1602                type INTEGER NOT NULL DEFAULT 0,
1603                PRIMARY KEY (package_id, source_id)
1604            )",
1605        )?;
1606        let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
1607        let type_int: i64 = match pin_type {
1608            PinType::Pinning => 2,
1609            PinType::Blocking => 4,
1610            PinType::Gating => 3,
1611        };
1612        conn.execute(
1613            &format!(
1614                "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
1615            ),
1616            rusqlite::params![package_id, version, source_id, type_int],
1617        )?;
1618        Ok(())
1619    }
1620
1621    pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
1622        let db_path = pins_db_path(&self.app_root);
1623        if !db_path.exists() {
1624            return Ok(false);
1625        }
1626        let conn = Connection::open(&db_path)?;
1627        let count = if let Some(source_id) = source_id {
1628            conn.execute(
1629                "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
1630                rusqlite::params![package_id, source_id],
1631            )?
1632        } else {
1633            conn.execute("DELETE FROM pin WHERE package_id = ?1", rusqlite::params![package_id])?
1634        };
1635        Ok(count > 0)
1636    }
1637
1638    pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
1639        let db_path = pins_db_path(&self.app_root);
1640        if !db_path.exists() {
1641            return Ok(());
1642        }
1643
1644        if let Some(source_id) = source_id {
1645            let conn = Connection::open(&db_path)?;
1646            conn.execute("DELETE FROM pin WHERE source_id = ?1", rusqlite::params![source_id])?;
1647        } else {
1648            fs::remove_file(&db_path)?;
1649        }
1650        Ok(())
1651    }
1652
1653    fn save_store(&self) -> Result<()> {
1654        save_store(&self.app_root, &self.store)
1655    }
1656
1657    fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
1658        source_state_dir(&self.app_root, source)
1659    }
1660
1661    // ── Install / download ──
1662
1663    pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<(Manifest, PathBuf)> {
1664        self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
1665    }
1666
1667    pub fn download_installer_for_request(
1668        &mut self,
1669        request: &InstallRequest,
1670        download_dir: &Path,
1671    ) -> Result<(Manifest, PathBuf)> {
1672        let manifest = self.resolve_manifest_for_install(request)?;
1673        let installer = select_installer(&manifest.installers, &request.query)
1674            .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
1675        let url = installer
1676            .url
1677            .as_deref()
1678            .ok_or_else(|| anyhow!("Installer has no URL"))?;
1679
1680        fs::create_dir_all(download_dir)?;
1681        let filename = request.rename.as_deref().unwrap_or_else(|| {
1682            url.rsplit('/')
1683                .next()
1684                .unwrap_or("installer")
1685                .split('?')
1686                .next()
1687                .unwrap_or("installer")
1688        });
1689        let dest = download_dir.join(filename);
1690
1691        let response = self.client.get(url).send().context("failed to download installer")?;
1692        if !response.status().is_success() {
1693            bail!("Download failed: HTTP {}", response.status());
1694        }
1695        let bytes = response.bytes()?;
1696        fs::write(&dest, &bytes)?;
1697
1698        if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
1699            let _ = fs::remove_file(&dest);
1700            return Err(error);
1701        }
1702
1703        Ok((manifest, dest))
1704    }
1705
1706    pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1707        let mut request = InstallRequest::new(query.clone());
1708        request.mode = if silent {
1709            InstallerMode::Silent
1710        } else {
1711            InstallerMode::SilentWithProgress
1712        };
1713        self.install_request(&request)
1714    }
1715
1716    pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
1717        let mut request = InstallRequest::new(query.clone());
1718        request.mode = mode;
1719        self.install_request(&request)
1720    }
1721
1722    pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
1723        let manifest = self.resolve_manifest_for_install(request)?;
1724        if !package_actions_supported() {
1725            let installer_type = select_installer(&manifest.installers, &request.query)
1726                .and_then(|installer| installer.installer_type)
1727                .unwrap_or_else(|| "install".to_owned())
1728                .to_lowercase();
1729            return Ok(unsupported_action_result(
1730                manifest.id.clone(),
1731                manifest.version.clone(),
1732                installer_type,
1733                INSTALL_UNSUPPORTED_WARNING,
1734            ));
1735        }
1736
1737        let existing_match = self.find_installed_package_for_install(request, &manifest)?;
1738        if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
1739            return Ok(no_op_result);
1740        }
1741
1742        Self::ensure_package_agreements_accepted(&manifest, request)?;
1743        self.install_dependencies(&manifest, request, &mut HashSet::new())?;
1744
1745        if request.dependencies_only {
1746            return Ok(InstallResult {
1747                package_id: manifest.id.clone(),
1748                version: manifest.version.clone(),
1749                installer_path: PathBuf::new(),
1750                installer_type: "dependencies".to_owned(),
1751                exit_code: 0,
1752                success: true,
1753                no_op: false,
1754                warnings: Vec::new(),
1755            });
1756        }
1757
1758        let installer = select_installer(&manifest.installers, &request.query)
1759            .ok_or_else(|| anyhow!("No applicable installer found"))?;
1760
1761        if request.uninstall_previous {
1762            let mut uninstall_request = UninstallRequest::new(PackageQuery {
1763                id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1764                query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
1765                source: request.query.source.clone(),
1766                exact: true,
1767                version: request.query.version.clone(),
1768                ..PackageQuery::default()
1769            });
1770            uninstall_request.product_code = installer.product_code.clone();
1771            uninstall_request.mode = InstallerMode::Silent;
1772            uninstall_request.all_versions = true;
1773            uninstall_request.force = true;
1774            let _ = self.uninstall_request(&uninstall_request);
1775        }
1776
1777        let temp_dir = std::env::temp_dir().join("pinget-install");
1778        let (_, installer_path) = self.download_installer_for_request(request, &temp_dir)?;
1779
1780        let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
1781
1782        let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
1783
1784        Ok(InstallResult {
1785            package_id: manifest.id.clone(),
1786            version: manifest.version.clone(),
1787            installer_path,
1788            installer_type,
1789            exit_code,
1790            success: exit_code == 0,
1791            no_op: false,
1792            warnings: Vec::new(),
1793        })
1794    }
1795
1796    fn find_installed_package_for_install(
1797        &mut self,
1798        request: &InstallRequest,
1799        manifest: &Manifest,
1800    ) -> Result<Option<ListMatch>> {
1801        let installed_matches = self.list(&ListQuery {
1802            query: request.query.query.clone(),
1803            id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1804            name: request.query.name.clone(),
1805            moniker: request.query.moniker.clone(),
1806            tag: None,
1807            command: None,
1808            product_code: None,
1809            version: None,
1810            source: request.query.source.clone(),
1811            count: Some(100),
1812            exact: request.query.exact || !manifest.id.trim().is_empty(),
1813            install_scope: request.query.install_scope.clone(),
1814            upgrade_only: false,
1815            include_unknown: false,
1816            include_pinned: false,
1817        })?;
1818
1819        Ok(installed_matches.matches.into_iter().find(|candidate| {
1820            candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
1821        }))
1822    }
1823
1824    fn create_install_no_op_result(
1825        request: &InstallRequest,
1826        manifest: &Manifest,
1827        existing_match: Option<&ListMatch>,
1828    ) -> Option<InstallResult> {
1829        let existing_match = existing_match?;
1830
1831        if request.no_upgrade {
1832            return Some(InstallResult {
1833                package_id: manifest.id.clone(),
1834                version: existing_match.installed_version.clone(),
1835                installer_path: PathBuf::new(),
1836                installer_type: "install".to_owned(),
1837                exit_code: 0,
1838                success: true,
1839                no_op: true,
1840                warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
1841            });
1842        }
1843
1844        if !request.force
1845            && !existing_match.installed_version.trim().is_empty()
1846            && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
1847        {
1848            return Some(InstallResult {
1849                package_id: manifest.id.clone(),
1850                version: existing_match.installed_version.clone(),
1851                installer_path: PathBuf::new(),
1852                installer_type: "install".to_owned(),
1853                exit_code: 0,
1854                success: true,
1855                no_op: true,
1856                warnings: vec![
1857                    "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
1858                ],
1859            });
1860        }
1861
1862        None
1863    }
1864
1865    pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
1866        if request.manifest_path.is_none() && !package_actions_supported() {
1867            let package_id = request
1868                .query
1869                .id
1870                .clone()
1871                .or_else(|| request.query.name.clone())
1872                .or_else(|| request.query.moniker.clone())
1873                .or_else(|| request.query.query.clone())
1874                .or_else(|| request.product_code.clone())
1875                .unwrap_or_else(|| "repair".to_owned());
1876            return Ok(unsupported_action_result(
1877                package_id,
1878                request.query.version.clone().unwrap_or_default(),
1879                "repair".to_owned(),
1880                REPAIR_UNSUPPORTED_WARNING,
1881            ));
1882        }
1883
1884        let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
1885        let installed_match = if request.manifest_path.is_some() {
1886            None
1887        } else {
1888            let installed = self.list(&Self::create_repair_list_query(request))?;
1889            warnings.extend(installed.warnings);
1890            if installed.matches.is_empty() {
1891                bail!("No installed package matched the supplied repair query.");
1892            }
1893            if installed.matches.len() > 1 {
1894                bail!("Multiple installed packages matched the supplied repair query.");
1895            }
1896            installed.matches.into_iter().next()
1897        };
1898
1899        let mut result =
1900            self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
1901        warnings.extend(result.warnings);
1902        result.warnings = warnings;
1903        Ok(result)
1904    }
1905
1906    pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1907        let mut request = UninstallRequest::new(query.clone());
1908        request.mode = if silent {
1909            InstallerMode::Silent
1910        } else {
1911            InstallerMode::SilentWithProgress
1912        };
1913        self.uninstall_request(&request)
1914    }
1915
1916    pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
1917        if !package_actions_supported() {
1918            let (package_id, version) = if let Some(path) = &request.manifest_path {
1919                let manifest = Self::load_manifest_from_path(path)?;
1920                (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
1921            } else {
1922                (
1923                    request
1924                        .query
1925                        .id
1926                        .clone()
1927                        .or_else(|| request.query.query.clone())
1928                        .or_else(|| request.query.name.clone())
1929                        .or_else(|| request.query.moniker.clone())
1930                        .or_else(|| request.product_code.clone())
1931                        .unwrap_or_else(|| "unknown".to_owned()),
1932                    request.query.version.clone().unwrap_or_default(),
1933                )
1934            };
1935
1936            return Ok(unsupported_action_result(
1937                package_id,
1938                version,
1939                "uninstall",
1940                UNINSTALL_UNSUPPORTED_WARNING,
1941            ));
1942        }
1943
1944        let matches = self.resolve_uninstall_matches(request)?;
1945        let mut exit_code = 0;
1946
1947        for installed in &matches {
1948            exit_code = uninstall_package(installed, request)?;
1949            if exit_code != 0 && !request.all_versions {
1950                break;
1951            }
1952        }
1953
1954        let installed = matches
1955            .first()
1956            .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
1957
1958        Ok(InstallResult {
1959            package_id: installed.id.clone(),
1960            version: installed.installed_version.clone(),
1961            installer_path: PathBuf::new(),
1962            installer_type: "uninstall".to_owned(),
1963            exit_code,
1964            success: exit_code == 0,
1965            no_op: false,
1966            warnings: Vec::new(),
1967        })
1968    }
1969
1970    fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
1971        if let Some(path) = &request.manifest_path {
1972            return Self::load_manifest_from_path(path);
1973        }
1974
1975        let (located, _warnings) = self.find_single_match(&request.query)?;
1976        let (manifest, _, _cached_files) = self.manifest_for_match(&located, &request.query)?;
1977        Ok(manifest)
1978    }
1979
1980    fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
1981        if !request.accept_package_agreements && !manifest.agreements.is_empty() {
1982            bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
1983        }
1984        Ok(())
1985    }
1986
1987    fn install_dependencies(
1988        &mut self,
1989        manifest: &Manifest,
1990        request: &InstallRequest,
1991        visited: &mut HashSet<String>,
1992    ) -> Result<()> {
1993        if request.skip_dependencies {
1994            return Ok(());
1995        }
1996
1997        let dependency_ids = manifest
1998            .package_dependencies
1999            .iter()
2000            .chain(
2001                manifest
2002                    .installers
2003                    .iter()
2004                    .flat_map(|installer| installer.package_dependencies.iter()),
2005            )
2006            .filter(|value| !value.trim().is_empty())
2007            .cloned()
2008            .collect::<BTreeSet<_>>();
2009
2010        for dependency_id in dependency_ids {
2011            if !visited.insert(dependency_id.clone()) {
2012                continue;
2013            }
2014
2015            let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2016            self.install_request(&dependency_request)?;
2017        }
2018
2019        Ok(())
2020    }
2021
2022    fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2023        let mut dependency_request = InstallRequest::new(PackageQuery {
2024            id: Some(dependency_id),
2025            source: request
2026                .dependency_source
2027                .clone()
2028                .or_else(|| request.query.source.clone()),
2029            exact: true,
2030            ..PackageQuery::default()
2031        });
2032        dependency_request.mode = match request.mode {
2033            InstallerMode::Interactive => InstallerMode::Interactive,
2034            InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2035        };
2036        dependency_request.accept_package_agreements = request.accept_package_agreements;
2037        dependency_request.no_upgrade = true;
2038        dependency_request.ignore_security_hash = request.ignore_security_hash;
2039        dependency_request.dependency_source = request.dependency_source.clone();
2040        dependency_request
2041    }
2042
2043    fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2044        ListQuery {
2045            query: request.query.query.clone(),
2046            id: request.query.id.clone(),
2047            name: request.query.name.clone(),
2048            moniker: request.query.moniker.clone(),
2049            product_code: request.product_code.clone(),
2050            version: request.query.version.clone(),
2051            source: request.query.source.clone(),
2052            count: Some(100),
2053            exact: request.query.exact,
2054            install_scope: request.query.install_scope.clone(),
2055            ..ListQuery::default()
2056        }
2057    }
2058
2059    fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2060        let mut query = request.query.clone();
2061        query.query = if installed_match.is_none() {
2062            request.query.query.clone()
2063        } else {
2064            None
2065        };
2066        query.id = installed_match
2067            .map(|item| item.id.clone())
2068            .or_else(|| request.query.id.clone());
2069        query.name = if installed_match.is_none() {
2070            request.query.name.clone()
2071        } else {
2072            None
2073        };
2074        query.moniker = if installed_match.is_none() {
2075            request.query.moniker.clone()
2076        } else {
2077            None
2078        };
2079        query.source = request
2080            .query
2081            .source
2082            .clone()
2083            .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2084        query.exact = true;
2085        query.version = request
2086            .query
2087            .version
2088            .clone()
2089            .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2090
2091        InstallRequest {
2092            query,
2093            manifest_path: request.manifest_path.clone(),
2094            mode: request.mode,
2095            log_path: request.log_path.clone(),
2096            custom: None,
2097            override_args: None,
2098            install_location: None,
2099            skip_dependencies: false,
2100            dependencies_only: false,
2101            accept_package_agreements: request.accept_package_agreements,
2102            force: true,
2103            rename: None,
2104            uninstall_previous: false,
2105            ignore_security_hash: request.ignore_security_hash,
2106            dependency_source: None,
2107            no_upgrade: false,
2108        }
2109    }
2110
2111    fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2112        let mut effective_query = request.query.clone();
2113        let mut effective_product_code = request.product_code.clone();
2114
2115        if let Some(path) = &request.manifest_path {
2116            let manifest = Self::load_manifest_from_path(path)?;
2117            effective_query = PackageQuery {
2118                query: Some(manifest.id.clone()),
2119                id: Some(manifest.id.clone()),
2120                name: Some(manifest.name.clone()),
2121                exact: true,
2122                version: effective_query.version.clone().or(Some(manifest.version)),
2123                ..PackageQuery::default()
2124            };
2125            effective_product_code = effective_product_code.or_else(|| {
2126                manifest
2127                    .installers
2128                    .iter()
2129                    .find_map(|installer| installer.product_code.clone())
2130            });
2131        }
2132
2133        let list_query = ListQuery {
2134            query: effective_query.query.clone(),
2135            id: effective_query.id.clone(),
2136            name: effective_query.name.clone(),
2137            moniker: effective_query.moniker.clone(),
2138            tag: None,
2139            command: None,
2140            product_code: effective_product_code,
2141            version: effective_query.version.clone(),
2142            source: effective_query.source.clone(),
2143            count: if request.all_versions { None } else { Some(100) },
2144            exact: effective_query.exact,
2145            install_scope: effective_query.install_scope,
2146            upgrade_only: false,
2147            include_unknown: false,
2148            include_pinned: false,
2149        };
2150
2151        let list_result = self.list(&list_query)?;
2152        if list_result.matches.is_empty() {
2153            bail!("No installed package found matching the query");
2154        }
2155        if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2156            bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2157        }
2158
2159        Ok(if request.all_versions {
2160            list_result.matches
2161        } else {
2162            vec![list_result.matches[0].clone()]
2163        })
2164    }
2165
2166    fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2167        let resolved = resolve_manifest_path(manifest_path)?;
2168        let bytes =
2169            fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2170        parse_yaml_manifest(&bytes)
2171    }
2172
2173    fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2174        self.find_single_match_with_semantics(query, SearchSemantics::Single)
2175    }
2176
2177    fn find_single_match_with_semantics(
2178        &mut self,
2179        query: &PackageQuery,
2180        semantics: SearchSemantics,
2181    ) -> Result<(LocatedMatch, Vec<String>)> {
2182        let (matches, warnings, _) = self.search_located(query, semantics)?;
2183
2184        if matches.is_empty() {
2185            bail!("no package matched the supplied query");
2186        }
2187
2188        if matches.len() > 1 {
2189            let choices = matches
2190                .iter()
2191                .take(10)
2192                .map(|item| {
2193                    format!(
2194                        "{} [{}] ({})",
2195                        item.display.name, item.display.id, item.display.source_name
2196                    )
2197                })
2198                .collect::<Vec<_>>()
2199                .join(", ");
2200            bail!("multiple packages matched: {choices}");
2201        }
2202
2203        Ok((matches.into_iter().next().expect("one match"), warnings))
2204    }
2205
2206    fn search_located(
2207        &mut self,
2208        query: &PackageQuery,
2209        semantics: SearchSemantics,
2210    ) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
2211        let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2212        let mut matches = Vec::new();
2213        let mut warnings = Vec::new();
2214        let mut truncated = false;
2215
2216        for index in indexes {
2217            match self.search_source(index, query, semantics) {
2218                Ok(mut source_matches) => {
2219                    truncated |= source_matches.truncated;
2220                    matches.append(&mut source_matches.matches);
2221                }
2222                Err(_error) => warnings.push(format!(
2223                    "Failed when searching source; results will not be included: {}",
2224                    self.store.sources[index].name
2225                )),
2226            }
2227        }
2228
2229        if semantics == SearchSemantics::Many {
2230            matches.sort_by(|left, right| {
2231                search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2232            });
2233            let limit = max_results(query);
2234            if matches.len() > limit {
2235                truncated = true;
2236                matches.truncate(limit);
2237            }
2238        }
2239
2240        Ok((matches, warnings, truncated))
2241    }
2242
2243    fn search_source(
2244        &mut self,
2245        source_index: usize,
2246        query: &PackageQuery,
2247        semantics: SearchSemantics,
2248    ) -> Result<SearchSourceMatches> {
2249        match self.store.sources[source_index].kind {
2250            SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2251            SourceKind::Rest => self.search_rest(source_index, query, semantics),
2252        }
2253    }
2254
2255    fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2256        match &located.locator {
2257            MatchLocator::PreIndexedV1 { package_rowid } => {
2258                let source = self.source_clone(located.source_index);
2259                let connection = self.open_preindexed_connection(located.source_index)?;
2260                let versions = query_v1_versions(&connection, *package_rowid)?;
2261                let mut keys = versions
2262                    .into_iter()
2263                    .map(|row| VersionKey {
2264                        version: row.version,
2265                        channel: row.channel,
2266                    })
2267                    .collect::<Vec<_>>();
2268                sort_versions_desc(&mut keys);
2269                if keys.is_empty() {
2270                    bail!("no versions found for {} in {}", located.display.id, source.name);
2271                }
2272                Ok(keys)
2273            }
2274            MatchLocator::PreIndexedV2 {
2275                package_rowid,
2276                package_hash,
2277            } => {
2278                let source = self.source_clone(located.source_index);
2279                let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2280                let mut keys = entries
2281                    .into_iter()
2282                    .map(|entry| VersionKey {
2283                        version: entry.version,
2284                        channel: String::new(),
2285                    })
2286                    .collect::<Vec<_>>();
2287                sort_versions_desc(&mut keys);
2288                Ok(keys)
2289            }
2290            MatchLocator::Rest { versions, .. } => {
2291                let mut keys = versions.clone();
2292                sort_versions_desc(&mut keys);
2293                Ok(keys)
2294            }
2295        }
2296    }
2297
2298    fn manifest_for_match(
2299        &mut self,
2300        located: &LocatedMatch,
2301        query: &PackageQuery,
2302    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2303        match &located.locator {
2304            MatchLocator::PreIndexedV1 { package_rowid } => {
2305                let source = self.source_clone(located.source_index);
2306                let connection = self.open_preindexed_connection(located.source_index)?;
2307                let versions = query_v1_versions(&connection, *package_rowid)?;
2308                let selected = select_v1_version(&versions, query.version.as_deref(), query.channel.as_deref())?;
2309                let relative_path = resolve_v1_relative_path(&connection, selected.pathpart_id)?;
2310                let bytes =
2311                    self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2312                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2313                manifest.version = selected.version.clone();
2314                manifest.channel = selected.channel;
2315                Ok((manifest, manifest_documents, vec![bytes.path]))
2316            }
2317            MatchLocator::PreIndexedV2 {
2318                package_rowid,
2319                package_hash,
2320            } => {
2321                let source = self.source_clone(located.source_index);
2322                let (entries, version_data_file) =
2323                    self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2324                let selected = select_v2_version(&entries, query.version.as_deref())?;
2325                let manifest_bytes = self.get_cached_source_file(
2326                    "V2_M",
2327                    &source,
2328                    &selected.manifest_relative_path,
2329                    Some(selected.manifest_hash.as_str()),
2330                )?;
2331                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2332                manifest.version = selected.version;
2333                Ok((
2334                    manifest,
2335                    manifest_documents,
2336                    vec![version_data_file, manifest_bytes.path],
2337                ))
2338            }
2339            MatchLocator::Rest { package_id, versions } => {
2340                let source = self.source_clone(located.source_index);
2341                let selected = select_rest_version(versions, query.version.as_deref(), query.channel.as_deref())?;
2342                let (bytes, cache_path) =
2343                    self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2344                let (manifest, manifest_documents) =
2345                    parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2346                Ok((manifest, manifest_documents, vec![cache_path]))
2347            }
2348        }
2349    }
2350
2351    fn search_preindexed(
2352        &mut self,
2353        source_index: usize,
2354        query: &PackageQuery,
2355        semantics: SearchSemantics,
2356    ) -> Result<SearchSourceMatches> {
2357        let connection = self.open_preindexed_connection(source_index)?;
2358        let source = self.source_clone(source_index);
2359
2360        match query_v2_matches(&connection, query, semantics) {
2361            Ok((rows, truncated)) => Ok(SearchSourceMatches {
2362                truncated,
2363                matches: rows
2364                    .into_iter()
2365                    .map(|row| LocatedMatch {
2366                        display: SearchMatch {
2367                            source_name: source.name.clone(),
2368                            source_kind: source.kind,
2369                            id: row.id.clone(),
2370                            name: row.name.clone(),
2371                            moniker: row.moniker.clone(),
2372                            version: Some(row.version.clone()),
2373                            channel: None,
2374                            match_criteria: row.match_criteria.clone(),
2375                        },
2376                        source_index,
2377                        locator: MatchLocator::PreIndexedV2 {
2378                            package_rowid: row.package_rowid,
2379                            package_hash: row.package_hash,
2380                        },
2381                    })
2382                    .collect(),
2383            }),
2384            Err(error) if can_fallback_to_v1(&error) => {
2385                let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
2386                let grouped = group_v1_rows(rows);
2387
2388                Ok(SearchSourceMatches {
2389                    truncated,
2390                    matches: grouped
2391                        .into_iter()
2392                        .map(|row| LocatedMatch {
2393                            display: SearchMatch {
2394                                source_name: source.name.clone(),
2395                                source_kind: source.kind,
2396                                id: row.id.clone(),
2397                                name: row.name.clone(),
2398                                moniker: row.moniker.clone(),
2399                                version: Some(row.version.clone()),
2400                                channel: Some(row.channel.clone()),
2401                                match_criteria: row.match_criteria.clone(),
2402                            },
2403                            source_index,
2404                            locator: MatchLocator::PreIndexedV1 {
2405                                package_rowid: row.package_rowid,
2406                            },
2407                        })
2408                        .collect(),
2409                })
2410            }
2411            Err(error) => Err(error),
2412        }
2413    }
2414
2415    fn search_rest(
2416        &mut self,
2417        source_index: usize,
2418        query: &PackageQuery,
2419        semantics: SearchSemantics,
2420    ) -> Result<SearchSourceMatches> {
2421        let source = self.source_clone(source_index);
2422        let info = self.load_rest_information(source_index)?;
2423        let contract = choose_contract(&info.server_supported_versions)
2424            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2425
2426        let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
2427        let body = build_rest_search_body(query, &info, semantics)?;
2428        let response = self
2429            .client
2430            .post(url)
2431            .header("Version", contract)
2432            .json(&body)
2433            .send()
2434            .context("REST search request failed")?
2435            .error_for_status()
2436            .context("REST search request returned an error")?;
2437        let json = response
2438            .json::<JsonValue>()
2439            .context("failed to parse REST search response")?;
2440        let data = json
2441            .get("Data")
2442            .and_then(JsonValue::as_array)
2443            .cloned()
2444            .unwrap_or_default();
2445        let max_results = source_fetch_results(query, semantics);
2446
2447        let mut results = Vec::new();
2448        for item in data {
2449            let package_id = json_string(&item, "PackageIdentifier")
2450                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
2451            let package_name =
2452                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
2453            let mut versions = parse_rest_versions(&item)?;
2454            sort_versions_desc(&mut versions);
2455            let latest = versions
2456                .first()
2457                .cloned()
2458                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
2459
2460            results.push(LocatedMatch {
2461                display: SearchMatch {
2462                    source_name: source.name.clone(),
2463                    source_kind: source.kind,
2464                    id: package_id.clone(),
2465                    name: package_name,
2466                    moniker: json_string(&item, "Moniker"),
2467                    version: Some(latest.version.clone()),
2468                    channel: if latest.channel.is_empty() {
2469                        None
2470                    } else {
2471                        Some(latest.channel.clone())
2472                    },
2473                    match_criteria: rest_match_criteria(&item, query, semantics),
2474                },
2475                source_index,
2476                locator: MatchLocator::Rest { package_id, versions },
2477            });
2478        }
2479
2480        Ok(SearchSourceMatches {
2481            truncated: results.len() >= max_results,
2482            matches: results,
2483        })
2484    }
2485
2486    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
2487        let source = self.source_clone(source_index);
2488        let index_path = preindexed_index_path(&self.app_root, &source);
2489        if !index_path.exists() {
2490            let _ = self.update_preindexed(source_index)?;
2491            if !self.use_system_winget_sources {
2492                self.save_store()?;
2493            }
2494        }
2495
2496        Self::open_sqlite_connection(index_path).context("failed to open preindexed index")
2497    }
2498
2499    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
2500        let path_text = path.to_string_lossy().into_owned();
2501        Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI)
2502            .with_context(|| format!("failed to open SQLite database at {path_text}"))
2503    }
2504
2505    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
2506        let source = &mut self.store.sources[source_index];
2507        let state_dir = source_state_dir(&self.app_root, source);
2508        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
2509
2510        let mut last_error = None;
2511        for candidate in PREINDEXED_CANDIDATES {
2512            let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
2513            match self.client.get(&url).send() {
2514                Ok(response) if response.status().is_success() => {
2515                    let header_version = response
2516                        .headers()
2517                        .get("x-ms-meta-sourceversion")
2518                        .and_then(|value| value.to_str().ok())
2519                        .map(str::to_owned);
2520                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
2521                    let payload = bytes.to_vec();
2522                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
2523                        .context("preindexed package did not contain Public/index.db")?;
2524
2525                    fs::write(preindexed_package_path(&self.app_root, source), &payload)
2526                        .context("failed to persist source package")?;
2527                    fs::write(preindexed_index_path(&self.app_root, source), index_bytes)
2528                        .context("failed to persist source index")?;
2529                    source.last_update = Some(Utc::now());
2530                    source.source_version = header_version;
2531                    return Ok(format!("downloaded {}", candidate));
2532                }
2533                Ok(response) => {
2534                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
2535                }
2536                Err(error) => {
2537                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
2538                }
2539            }
2540        }
2541
2542        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
2543    }
2544
2545    fn update_rest(&mut self, source_index: usize) -> Result<String> {
2546        let info = self.fetch_rest_information(source_index, true)?;
2547        let source = &mut self.store.sources[source_index];
2548        if !info.source_identifier.is_empty() {
2549            source.identifier = info.source_identifier.clone();
2550        }
2551        source.last_update = Some(Utc::now());
2552        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
2553        Ok("refreshed information cache".to_owned())
2554    }
2555
2556    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
2557        let source = self.source_clone(source_index);
2558        let cache_path = rest_information_cache_path(&self.app_root, &source);
2559
2560        if cache_path.exists() {
2561            let cache = serde_json::from_slice::<RestInfoCache>(
2562                &fs::read(&cache_path).context("failed to read REST information cache")?,
2563            )
2564            .context("failed to parse REST information cache")?;
2565            if cache.expires_at > Utc::now() {
2566                return Ok(cache.value);
2567            }
2568        }
2569
2570        self.fetch_rest_information(source_index, true)
2571    }
2572
2573    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
2574        let source = self.source_clone(source_index);
2575        let url = format!("{}/information", source.arg.trim_end_matches('/'));
2576        let response = self
2577            .client
2578            .get(url)
2579            .send()
2580            .context("REST information request failed")?
2581            .error_for_status()
2582            .context("REST information request returned an error")?;
2583
2584        let max_age = cache_control_max_age(&response);
2585        let json = response
2586            .json::<JsonValue>()
2587            .context("failed to parse REST information response")?;
2588        let data = json
2589            .get("Data")
2590            .cloned()
2591            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
2592        let info = serde_json::from_value::<RestInformation>(data)
2593            .context("failed to deserialize REST information payload")?;
2594
2595        if persist {
2596            let cache = RestInfoCache {
2597                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
2598                value: info.clone(),
2599            };
2600            write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
2601        }
2602
2603        Ok(info)
2604    }
2605
2606    fn load_v2_version_data(
2607        &self,
2608        source: &SourceRecord,
2609        package_rowid: i64,
2610        package_hash: &str,
2611    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
2612        let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
2613            .context("failed to reopen preindexed index for V2 version data")?;
2614        let package_hash = package_hash.to_ascii_lowercase();
2615        let package_id = query_optional_value(
2616            &connection,
2617            "SELECT id FROM packages WHERE rowid = ?1",
2618            vec![SqlValue::Integer(package_rowid)],
2619            |row| row_string(row, 0),
2620        )?
2621        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
2622        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
2623        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
2624        let yaml = decompress_mszyml(&file.bytes)?;
2625        let document =
2626            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
2627        Ok((document.versions, file.path))
2628    }
2629
2630    fn get_cached_source_file(
2631        &self,
2632        bucket: &str,
2633        source: &SourceRecord,
2634        relative_path: &str,
2635        expected_hash: Option<&str>,
2636    ) -> Result<CachedBytes> {
2637        let normalized_relative = relative_path.replace('\\', "/");
2638        let cache_path =
2639            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
2640
2641        if cache_path.exists() {
2642            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
2643            if hash_matches(expected_hash, &cached) {
2644                return Ok(CachedBytes {
2645                    path: cache_path,
2646                    bytes: cached,
2647                });
2648            }
2649        }
2650
2651        let url = format!(
2652            "{}/{}",
2653            source.arg.trim_end_matches('/'),
2654            normalized_relative.trim_start_matches('/')
2655        );
2656        let response = self
2657            .client
2658            .get(url)
2659            .send()
2660            .context("failed to fetch source file")?
2661            .error_for_status()
2662            .context("source file request returned an error")?;
2663        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
2664
2665        if let Some(hash) = expected_hash {
2666            verify_hash(hash, &bytes)?;
2667        }
2668
2669        if let Some(parent) = cache_path.parent() {
2670            fs::create_dir_all(parent).context("failed to create cache directory")?;
2671        }
2672        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
2673
2674        Ok(CachedBytes {
2675            path: cache_path,
2676            bytes,
2677        })
2678    }
2679
2680    fn get_or_fetch_rest_manifest(
2681        &mut self,
2682        source: &SourceRecord,
2683        package_id: &str,
2684        version: &str,
2685        channel: &str,
2686    ) -> Result<(Vec<u8>, PathBuf)> {
2687        let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
2688        if cache_path.exists() {
2689            return Ok((
2690                fs::read(&cache_path).context("failed to read cached REST manifest")?,
2691                cache_path,
2692            ));
2693        }
2694
2695        let info = self.load_rest_information_by_name(&source.name)?;
2696        let contract = choose_contract(&info.server_supported_versions)
2697            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2698        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
2699
2700        let mut params = vec![("Version", version.to_owned())];
2701        if !channel.is_empty() {
2702            params.push(("Channel", channel.to_owned()));
2703        }
2704        if info
2705            .required_query_parameters
2706            .iter()
2707            .any(|value| value.eq_ignore_ascii_case("Market"))
2708        {
2709            params.push(("Market", default_market()));
2710        }
2711
2712        let response = self
2713            .client
2714            .get(url)
2715            .header("Version", contract)
2716            .query(&params)
2717            .send()
2718            .context("REST manifest request failed")?
2719            .error_for_status()
2720            .context("REST manifest request returned an error")?;
2721        let bytes = response
2722            .bytes()
2723            .context("failed to read REST manifest response")?
2724            .to_vec();
2725
2726        if let Some(parent) = cache_path.parent() {
2727            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
2728        }
2729        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
2730
2731        Ok((bytes, cache_path))
2732    }
2733
2734    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
2735        let index = self
2736            .store
2737            .sources
2738            .iter()
2739            .position(|source| source.name.eq_ignore_ascii_case(name))
2740            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
2741        self.load_rest_information(index)
2742    }
2743
2744    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
2745        if let Some(name) = source_name {
2746            let index = self
2747                .store
2748                .sources
2749                .iter()
2750                .position(|source| source.name.eq_ignore_ascii_case(name))
2751                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
2752            return Ok(vec![index]);
2753        }
2754
2755        let mut indexes: Vec<_> = self
2756            .store
2757            .sources
2758            .iter()
2759            .enumerate()
2760            .filter(|(_, source)| !source.explicit)
2761            .collect();
2762        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
2763            right_source
2764                .priority
2765                .cmp(&left_source.priority)
2766                .then_with(|| left_index.cmp(right_index))
2767        });
2768        Ok(indexes.into_iter().map(|(index, _)| index).collect())
2769    }
2770
2771    fn source_clone(&self, index: usize) -> SourceRecord {
2772        self.store.sources[index].clone()
2773    }
2774
2775    fn refresh_system_winget_sources(&mut self) -> Result<()> {
2776        if self.use_system_winget_sources {
2777            self.store = load_system_winget_source_store()?;
2778        }
2779        Ok(())
2780    }
2781
2782    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
2783        self.refresh_system_winget_sources()?;
2784        let selected_names: Vec<_> = if source_name.is_none() {
2785            self.store.sources.iter().map(|source| source.name.clone()).collect()
2786        } else {
2787            self.resolve_source_indexes(source_name)?
2788                .into_iter()
2789                .map(|index| self.store.sources[index].name.clone())
2790                .collect()
2791        };
2792
2793        let detail = system_winget_update_sources(source_name)?;
2794        self.refresh_system_winget_sources()?;
2795
2796        Ok(selected_names
2797            .into_iter()
2798            .filter_map(|name| {
2799                self.store
2800                    .sources
2801                    .iter()
2802                    .find(|source| source.name.eq_ignore_ascii_case(&name))
2803                    .map(|source| SourceUpdateResult {
2804                        name: source.name.clone(),
2805                        kind: source.kind,
2806                        detail: detail.clone(),
2807                    })
2808            })
2809            .collect())
2810    }
2811}
2812
2813#[derive(Debug)]
2814struct CachedBytes {
2815    path: PathBuf,
2816    bytes: Vec<u8>,
2817}
2818
2819#[derive(Debug, Clone)]
2820struct V1SearchRow {
2821    manifest_rowid: i64,
2822    package_rowid: i64,
2823    version: String,
2824    channel: String,
2825    id: String,
2826    name: String,
2827    moniker: Option<String>,
2828    match_criteria: Option<String>,
2829}
2830
2831#[derive(Debug, Clone)]
2832struct V1VersionRow {
2833    version: String,
2834    channel: String,
2835    pathpart_id: i64,
2836    manifest_hash: Option<String>,
2837}
2838
2839#[derive(Debug, Clone)]
2840struct V2SearchRow {
2841    package_rowid: i64,
2842    package_hash: String,
2843    id: String,
2844    name: String,
2845    moniker: Option<String>,
2846    version: String,
2847    match_criteria: Option<String>,
2848}
2849
2850fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
2851    query.query.is_some()
2852        || query.id.is_some()
2853        || query.name.is_some()
2854        || query.moniker.is_some()
2855        || query.tag.is_some()
2856        || query.command.is_some()
2857        || query.source.is_some()
2858}
2859
2860fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
2861    PackageQuery {
2862        query: query.query.clone(),
2863        id: query.id.clone(),
2864        name: query.name.clone(),
2865        moniker: query.moniker.clone(),
2866        tag: query.tag.clone(),
2867        command: query.command.clone(),
2868        source: query.source.clone(),
2869        count: Some(LIST_LOOKUP_MAX_RESULTS),
2870        exact: query.exact,
2871        version: None,
2872        channel: None,
2873        locale: None,
2874        installer_type: None,
2875        installer_architecture: None,
2876        platform: None,
2877        os_version: None,
2878        install_scope: query.install_scope.clone(),
2879    }
2880}
2881
2882fn allow_loose_list_correlation(query: &ListQuery) -> bool {
2883    query.query.is_some() || query.id.is_some() || query.name.is_some()
2884}
2885
2886fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
2887    package
2888        .correlated
2889        .as_ref()
2890        .and_then(|candidate| candidate.version.as_deref())
2891        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
2892}
2893
2894fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
2895    package.installed_version.eq_ignore_ascii_case("Unknown")
2896}
2897
2898fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
2899    // Hide RequireExplicitUpgrade rows from bulk `upgrade` (Edge, Steam,
2900    // Discord). Explicit `--id` filters surface them regardless.
2901    if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
2902        return false;
2903    }
2904    // Hide rows whose latest catalog version has no installer the host can
2905    // run — winget refuses to upgrade past an unusable architecture.
2906    if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
2907        return false;
2908    }
2909    installed_package_has_upgrade(package)
2910        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
2911}
2912
2913/// Collapses installed entries that correlate to the same catalog package id
2914/// (case-insensitive within a source) down to a single representative — the
2915/// one with the highest installed version. Uncorrelated entries pass through
2916/// untouched; they can't appear in `upgrade` output anyway (the upgrade
2917/// filter requires a correlation), so they're effectively a no-op here.
2918fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
2919    use std::collections::HashMap;
2920    let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
2921    let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
2922
2923    for package in packages {
2924        match package.correlated.as_ref() {
2925            Some(candidate) => {
2926                let key = (
2927                    candidate.id.to_ascii_lowercase(),
2928                    candidate.source_name.to_ascii_lowercase(),
2929                );
2930                let keep_new = match by_id.get(&key) {
2931                    Some(existing) => {
2932                        // Prefer rows whose `installed_version` was remapped to
2933                        // a catalog Version (via aMiV/aMaV). Comparing a raw
2934                        // ARP DisplayVersion like `40.10.18029` against the
2935                        // catalog's `10.0.300` is meaningless and would also
2936                        // hide the row that legitimately upgrades to it.
2937                        match (
2938                            package.installed_version_canonical,
2939                            existing.installed_version_canonical,
2940                        ) {
2941                            (true, false) => true,
2942                            (false, true) => false,
2943                            _ => {
2944                                compare_version(&package.installed_version, &existing.installed_version)
2945                                    == Ordering::Greater
2946                            }
2947                        }
2948                    }
2949                    None => true,
2950                };
2951                if keep_new {
2952                    by_id.insert(key, package);
2953                }
2954            }
2955            None => uncorrelated.push(package),
2956        }
2957    }
2958
2959    let mut result = uncorrelated;
2960    result.extend(by_id.into_values());
2961    result
2962}
2963
2964fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
2965    let mut source_specific = None;
2966    let mut source_agnostic = None;
2967
2968    for pin in pins {
2969        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
2970            continue;
2971        }
2972
2973        if pin.source_id.is_empty() {
2974            if source_agnostic.is_none() {
2975                source_agnostic = Some(pin);
2976            }
2977        } else if item
2978            .source_name
2979            .as_deref()
2980            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
2981        {
2982            source_specific = Some(pin);
2983            break;
2984        }
2985    }
2986
2987    source_specific.or(source_agnostic)
2988}
2989
2990fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
2991    let Some(available_version) = item.available_version.as_deref() else {
2992        return false;
2993    };
2994    let Some(pin) = find_applicable_pin(item, pins) else {
2995        return false;
2996    };
2997
2998    match pin.pin_type {
2999        PinType::Blocking => true,
3000        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3001    }
3002}
3003
3004fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3005    if pattern.trim().is_empty() || pattern == "*" {
3006        return true;
3007    }
3008
3009    if !pattern.contains('*') {
3010        return version.eq_ignore_ascii_case(pattern);
3011    }
3012
3013    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3014        let prefix = &pattern[..pattern.len() - 1];
3015        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3016    }
3017
3018    version.eq_ignore_ascii_case(pattern)
3019}
3020
3021fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3022    let available_version = package.correlated.as_ref().and_then(|candidate| {
3023        candidate.version.as_ref().and_then(|candidate_version| {
3024            if installed_package_has_unknown_version(&package)
3025                || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3026            {
3027                Some(candidate_version.clone())
3028            } else {
3029                None
3030            }
3031        })
3032    });
3033    let source_name = package
3034        .correlated
3035        .as_ref()
3036        .map(|candidate| candidate.source_name.clone())
3037        .filter(|value| !value.is_empty());
3038    let id = package
3039        .correlated
3040        .as_ref()
3041        .map(|candidate| candidate.id.clone())
3042        .unwrap_or_else(|| package.local_id.clone());
3043
3044    ListMatch {
3045        name: package.name,
3046        id,
3047        local_id: package.local_id,
3048        installed_version: package.installed_version,
3049        available_version,
3050        source_name,
3051        publisher: package.publisher,
3052        scope: package.scope,
3053        installer_category: package.installer_category,
3054        install_location: package.install_location,
3055        package_family_names: package.package_family_names,
3056        product_codes: package.product_codes,
3057        upgrade_codes: package.upgrade_codes,
3058    }
3059}
3060
3061fn list_sort_weight(package: &InstalledPackage) -> usize {
3062    if package.local_id.starts_with("ARP\\") {
3063        0
3064    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3065        1
3066    } else {
3067        2
3068    }
3069}
3070
3071fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3072    let correlated = package.correlated.as_ref();
3073
3074    if let Some(value) = &query.id {
3075        let local_match = matches_text(&package.local_id, value, query.exact);
3076        let correlated_match = correlated
3077            .map(|candidate| matches_text(&candidate.id, value, query.exact))
3078            .unwrap_or(false);
3079        if !local_match && !correlated_match {
3080            return false;
3081        }
3082    }
3083
3084    if let Some(value) = &query.name
3085        && !matches_text(&package.name, value, query.exact)
3086    {
3087        return false;
3088    }
3089
3090    if let Some(value) = &query.product_code
3091        && !package
3092            .product_codes
3093            .iter()
3094            .any(|code| code.eq_ignore_ascii_case(value))
3095    {
3096        return false;
3097    }
3098
3099    if let Some(value) = &query.version
3100        && !package.installed_version.eq_ignore_ascii_case(value)
3101    {
3102        return false;
3103    }
3104
3105    if let Some(value) = &query.query {
3106        let local_match =
3107            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3108        let correlated_match = correlated
3109            .map(|candidate| {
3110                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3111            })
3112            .unwrap_or(false);
3113        if !local_match && !correlated_match {
3114            return false;
3115        }
3116    }
3117
3118    if let Some(source) = &query.source
3119        && correlated
3120            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3121            .unwrap_or(true)
3122    {
3123        return false;
3124    }
3125
3126    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3127        return false;
3128    }
3129
3130    true
3131}
3132
3133fn correlate_installed_package(
3134    package: &InstalledPackage,
3135    candidates: &[SearchMatch],
3136    allow_loose_name_match: bool,
3137) -> Option<SearchMatch> {
3138    // MSIX entries can only be correlated through their PackageFamilyName.
3139    // That lookup happens earlier in `correlate_installed_via_index`; if it
3140    // didn't find a match, name-based fallback is wrong — the catalog
3141    // doesn't carry this MSIX's identity, so any name collision (e.g. the
3142    // self-updating `Microsoft Edge Stable` MSIX or the Store stub
3143    // `Notepad++` MSIX) would manufacture a phantom correlation that winget
3144    // doesn't make.
3145    if package.local_id.starts_with("MSIX\\") {
3146        return None;
3147    }
3148
3149    let installed_name = normalize_correlation_name(&package.name);
3150    let installed_name_lower = package.name.to_ascii_lowercase();
3151    let candidate_names = correlation_name_candidates(&package.name);
3152
3153    let mut scored: Vec<(i32, SearchMatch)> = candidates
3154        .iter()
3155        .filter_map(|candidate| {
3156            let candidate_name = normalize_correlation_name(&candidate.name);
3157            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3158                1000
3159            } else if candidate_names.iter().any(|name| {
3160                let normalized = normalize_correlation_name(name);
3161                normalized == candidate_name
3162            }) {
3163                900
3164            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3165                700
3166            } else {
3167                0
3168            };
3169
3170            if base_score == 0 {
3171                return None;
3172            }
3173
3174            // Tiebreaker so we pick the *right* candidate when multiple catalog
3175            // entries collapse to the same normalized name or all loose-match a
3176            // long installed name. Two failure modes this addresses:
3177            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
3178            //     alphanumeric filter strips `+`/`-`), so both score 900 and
3179            //     `max_by_key` arbitrarily picked the latter.
3180            //   • Loose substring match picks up `Studio` inside
3181            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
3182            //     `Microsoft.DotNet.SDK.10` when both score 700.
3183            // The bonus rewards catalog names that appear verbatim in the
3184            // installed display name — strongly when they anchor the start,
3185            // weaker when they're embedded.
3186            let candidate_lower = candidate.name.to_ascii_lowercase();
3187            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3188                100
3189            } else if installed_name_lower.contains(&candidate_lower) {
3190                50
3191            } else {
3192                0
3193            };
3194
3195            Some((base_score + prefix_bonus, candidate.clone()))
3196        })
3197        .collect();
3198
3199    scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3200    let mut iter = scored.into_iter();
3201    let (top_score, top_match) = iter.next()?;
3202
3203    // Reject ambiguous wins. When the user has e.g. `Git` (publisher "The Git
3204    // Development Community") installed, both `Git.Git` and `Microsoft.Git`
3205    // normalize their names to `git` and tie at the same score — the only
3206    // signal that disambiguates them, publisher, isn't part of this scoring
3207    // function. winget refuses the correlation in this case (the install
3208    // shows up with an empty Source); we do the same to avoid manufacturing
3209    // a fake upgrade against the wrong package.
3210    if let Some((next_score, _)) = iter.next()
3211        && next_score == top_score
3212    {
3213        return None;
3214    }
3215    Some(top_match)
3216}
3217
3218fn correlation_name_candidates(name: &str) -> Vec<String> {
3219    let mut candidates = Vec::new();
3220    candidates.push(name.trim().to_owned());
3221
3222    let mut trimmed = name.trim().to_owned();
3223    if let Some(index) = trimmed.find(" (") {
3224        trimmed.truncate(index);
3225    }
3226
3227    let mut words = Vec::new();
3228    for token in trimmed.split_whitespace() {
3229        let lower = token
3230            .trim_matches(|ch: char| ch == '(' || ch == ')')
3231            .to_ascii_lowercase();
3232        if !words.is_empty()
3233            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3234        {
3235            break;
3236        }
3237        words.push(token);
3238    }
3239
3240    if !words.is_empty() {
3241        candidates.push(words.join(" "));
3242    }
3243
3244    candidates.sort();
3245    candidates.dedup();
3246    candidates
3247}
3248
3249fn normalize_correlation_name(value: &str) -> String {
3250    value
3251        .chars()
3252        .filter(|ch| ch.is_ascii_alphanumeric())
3253        .flat_map(|ch| ch.to_lowercase())
3254        .collect()
3255}
3256
3257struct V2PackageMetadata {
3258    rowid: i64,
3259    id: String,
3260    name: String,
3261    moniker: Option<String>,
3262    latest_version: String,
3263    package_hash: String,
3264}
3265
3266/// Looks up a v2 catalog package whose identity (PackageFamilyName, ProductCode,
3267/// or UpgradeCode) matches the installed package. Returns the `packages.rowid`
3268/// and the field that produced the match. This is winget's primary correlation
3269/// path; falling back to display-name matching is only correct for ARP entries
3270/// that lack all three identifiers.
3271fn lookup_identity_match_v2(
3272    connection: &Connection,
3273    package: &InstalledPackage,
3274) -> Result<Option<(i64, &'static str)>> {
3275    for pfn in &package.package_family_names {
3276        if let Some(rowid) = query_optional_value(
3277            connection,
3278            "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3279            vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3280            |row| row_i64(row, 0),
3281        )? {
3282            return Ok(Some((rowid, "PackageFamilyName")));
3283        }
3284    }
3285    // UpgradeCode wins over ProductCode. The UpgradeCode is the MSI *family*
3286    // identity — it stays stable across versions, while ProductCode changes
3287    // every release. When a single ProductCode happens to also be listed
3288    // under a sibling catalog package (e.g. `OpenJS.NodeJS.LTS` carries
3289    // installer rows from earlier LTS-line ProductCodes, while the v24
3290    // install actually belongs to `OpenJS.NodeJS.22` via its UpgradeCode),
3291    // the upgrade-side match represents the user's "package family" — the
3292    // one winget uses.
3293    for code in &package.upgrade_codes {
3294        if let Some(rowid) = query_optional_value(
3295            connection,
3296            "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3297            vec![SqlValue::Text(code.to_ascii_lowercase())],
3298            |row| row_i64(row, 0),
3299        )? {
3300            return Ok(Some((rowid, "UpgradeCode")));
3301        }
3302    }
3303    for code in &package.product_codes {
3304        if let Some(rowid) = query_optional_value(
3305            connection,
3306            "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3307            vec![SqlValue::Text(code.to_ascii_lowercase())],
3308            |row| row_i64(row, 0),
3309        )? {
3310            return Ok(Some((rowid, "ProductCode")));
3311        }
3312    }
3313    Ok(None)
3314}
3315
3316/// Resolves a v2 package by its catalog id back to its rowid + hash so the
3317/// post-correlation enrichment pass can load versionData for rows that
3318/// correlated through the name-based fallback (which throws the rowid away).
3319fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3320    query_optional_value(
3321        connection,
3322        "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3323        vec![SqlValue::Text(package_id.to_owned())],
3324        |row| {
3325            Ok(V2PackageMetadata {
3326                rowid: row_i64(row, 0)?,
3327                id: row_string(row, 1)?,
3328                name: row_string(row, 2)?,
3329                moniker: row_opt_string(row, 3)?,
3330                latest_version: row_string(row, 4)?,
3331                package_hash: row_hex_string(row, 5)?,
3332            })
3333        },
3334    )
3335}
3336
3337/// MSIX entries whose Windows resource-string display name (e.g.
3338/// `ms-resource:appDisplayName`) hasn't been resolved by the AppX runtime
3339/// would otherwise leak that placeholder into pinget's output. winget
3340/// resolves it via `Windows.Management.Deployment` and shows the catalog's
3341/// `PackageName`; we mirror that by substituting the correlated catalog
3342/// name once the package is correlated, which is the parity-faithful
3343/// substitute we can produce without taking a WinRT dependency.
3344fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3345    if !package.local_id.starts_with("MSIX\\") {
3346        return;
3347    }
3348    if !package.name.starts_with("ms-resource:") {
3349        return;
3350    }
3351    let Some(candidate) = package.correlated.as_ref() else {
3352        return;
3353    };
3354    if !candidate.name.is_empty() {
3355        package.name = candidate.name.clone();
3356    }
3357}
3358
3359fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3360    query_optional_value(
3361        connection,
3362        "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3363        vec![SqlValue::Integer(package_rowid)],
3364        |row| {
3365            Ok(V2PackageMetadata {
3366                rowid: package_rowid,
3367                id: row_string(row, 0)?,
3368                name: row_string(row, 1)?,
3369                moniker: row_opt_string(row, 2)?,
3370                latest_version: row_string(row, 3)?,
3371                package_hash: row_hex_string(row, 4)?,
3372            })
3373        },
3374    )
3375}
3376
3377fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3378    query_optional_value(
3379        connection,
3380        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3381        Vec::new(),
3382        |row| row_i64(row, 0),
3383    )
3384    .ok()
3385    .flatten()
3386    .is_some()
3387}
3388
3389/// Returns the catalog `packages.rowid` whose `(norm_name, norm_publisher)`
3390/// pair matches the installed package — if and only if the match is
3391/// unique. Multiple matches mean the normalization wasn't strong enough to
3392/// disambiguate (e.g. an ARP DisplayName that normalizes the same as two
3393/// distinct catalog packages); winget refuses to correlate in that case
3394/// and we do the same.
3395fn lookup_unique_normalized_identity(
3396    connection: &Connection,
3397    norm_name: &str,
3398    norm_publisher: &str,
3399) -> Result<Option<i64>> {
3400    let rows: Vec<i64> = query_rows(
3401        connection,
3402        "SELECT DISTINCT n.package FROM norm_names2 n \
3403         WHERE n.norm_name = ?1 \
3404         AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3405         LIMIT 2",
3406        vec![
3407            SqlValue::Text(norm_name.to_owned()),
3408            SqlValue::Text(norm_publisher.to_owned()),
3409        ],
3410        |row| row_i64(row, 0),
3411    )?;
3412    if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3413}
3414
3415fn v2_identity_tables_present(connection: &Connection) -> bool {
3416    query_optional_value(
3417        connection,
3418        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3419        Vec::new(),
3420        |row| row_i64(row, 0),
3421    )
3422    .ok()
3423    .flatten()
3424    .is_some()
3425}
3426
3427/// Translates an ARP DisplayVersion to the catalog Version it maps to.
3428/// For packages like the .NET SDK or Python the ARP version is an MSI build
3429/// number (e.g. `10.1.826.23019`) unrelated to the public version
3430/// (`10.0.108`). winget records the ARP range per catalog version in
3431/// versionData.mszyml as `aMiV` / `aMaV`; we look for the entry whose range
3432/// contains the installed version.
3433fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3434    if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3435        return None;
3436    }
3437    for entry in entries {
3438        let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3439            continue;
3440        };
3441        if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3442        {
3443            return Some(entry.version.clone());
3444        }
3445    }
3446    None
3447}
3448
3449/// Returns the latest catalog Version that carries ARP-range metadata
3450/// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also
3451/// publish "internal" version rows whose `v` is an MSI build number
3452/// (`8000.836.2153.0`) without ARP bounds. Those rows are not user-facing
3453/// upgrade targets — when the installed side was matched through an ARP
3454/// range, the available side must be a peer that also exposes ARP bounds so
3455/// the two versions compare on the same scale. Returns `None` when no entry
3456/// has bounds; the caller should fall back to `packages.latest_version`.
3457fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3458    entries
3459        .iter()
3460        .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3461        .max_by(|a, b| compare_version(&a.version, &b.version))
3462        .map(|entry| entry.version.clone())
3463}
3464
3465#[cfg(windows)]
3466fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3467    let mut packages = Vec::new();
3468    let mut seen = BTreeSet::new();
3469
3470    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3471    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3472
3473    // MSI registers UpgradeCode → ProductCode mappings under
3474    // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes` (per-machine) and
3475    // `HKCU\Software\Microsoft\Installer\UpgradeCodes` (per-user). Most ARP
3476    // entries don't expose `UpgradeCode` directly, so winget reads it from
3477    // here. Without it, packages like `OpenJS.NodeJS.22` (correlated only via
3478    // UpgradeCode in the v2 index) fall back to a sibling correlation (e.g.
3479    // `OpenJS.NodeJS.LTS`) that manufactures a spurious upgrade.
3480    let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3481
3482    if machine {
3483        collect_uninstall_view(
3484            &mut packages,
3485            &mut seen,
3486            &upgrade_codes,
3487            RegKey::predef(HKEY_LOCAL_MACHINE),
3488            "Machine",
3489            "X64",
3490            KEY_READ | KEY_WOW64_64KEY,
3491        )?;
3492        collect_uninstall_view(
3493            &mut packages,
3494            &mut seen,
3495            &upgrade_codes,
3496            RegKey::predef(HKEY_LOCAL_MACHINE),
3497            "Machine",
3498            "X86",
3499            KEY_READ | KEY_WOW64_32KEY,
3500        )?;
3501        collect_appmodel_packages(
3502            &mut packages,
3503            &mut seen,
3504            RegKey::predef(HKEY_LOCAL_MACHINE),
3505            "Machine",
3506            KEY_READ | KEY_WOW64_64KEY,
3507        )?;
3508    }
3509
3510    if user {
3511        collect_uninstall_view(
3512            &mut packages,
3513            &mut seen,
3514            &upgrade_codes,
3515            RegKey::predef(HKEY_CURRENT_USER),
3516            "User",
3517            "X64",
3518            KEY_READ,
3519        )?;
3520        collect_appmodel_packages(
3521            &mut packages,
3522            &mut seen,
3523            RegKey::predef(HKEY_CURRENT_USER),
3524            "User",
3525            KEY_READ,
3526        )?;
3527    }
3528
3529    Ok(packages)
3530}
3531
3532/// Builds a `ProductCode → UpgradeCode` map (both in standard
3533/// `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` lowercase format) by walking the
3534/// MSI Installer registry. Subkeys under `Installer\UpgradeCodes` are named
3535/// by the flipped UpgradeCode GUID, and each subkey's *value names* are the
3536/// flipped ProductCodes registered under that UpgradeCode.
3537#[cfg(windows)]
3538fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> std::collections::HashMap<String, String> {
3539    use std::collections::HashMap;
3540    let mut map = HashMap::new();
3541    if include_machine {
3542        collect_msi_upgrade_codes_from(
3543            RegKey::predef(HKEY_LOCAL_MACHINE),
3544            r"SOFTWARE\Classes\Installer\UpgradeCodes",
3545            KEY_READ | KEY_WOW64_64KEY,
3546            &mut map,
3547        );
3548    }
3549    if include_user {
3550        collect_msi_upgrade_codes_from(
3551            RegKey::predef(HKEY_CURRENT_USER),
3552            r"Software\Microsoft\Installer\UpgradeCodes",
3553            KEY_READ,
3554            &mut map,
3555        );
3556    }
3557    map
3558}
3559
3560#[cfg(windows)]
3561fn collect_msi_upgrade_codes_from(
3562    root: RegKey,
3563    path: &str,
3564    flags: u32,
3565    map: &mut std::collections::HashMap<String, String>,
3566) {
3567    let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
3568        return;
3569    };
3570    for key_name in upgrade_codes.enum_keys().flatten() {
3571        let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
3572            continue;
3573        };
3574        let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
3575            continue;
3576        };
3577        for (value_name, _) in subkey.enum_values().flatten() {
3578            if let Some(product_code) = unflip_packed_guid(&value_name) {
3579                // ARP-side keys are likely to win the latest insert; first one
3580                // is fine — we just need *any* mapping. Use `entry` so the
3581                // first writer per ProductCode wins to stay deterministic.
3582                map.entry(product_code).or_insert_with(|| upgrade_code.clone());
3583            }
3584        }
3585    }
3586}
3587
3588/// Converts the 32-character "packed GUID" used inside the MSI Installer
3589/// registry hive back to the standard `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`
3590/// lowercase form. The packing reverses each of the 11 chunks (sized 8/4/4
3591/// then eight 2-char byte pairs) of the GUID's hex representation.
3592#[cfg(windows)]
3593fn unflip_packed_guid(packed: &str) -> Option<String> {
3594    if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
3595        return None;
3596    }
3597    const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
3598    let bytes = packed.as_bytes();
3599    let mut reversed = String::with_capacity(32);
3600    let mut offset = 0;
3601    for size in CHUNKS {
3602        let chunk = &bytes[offset..offset + size];
3603        for &c in chunk.iter().rev() {
3604            reversed.push(c as char);
3605        }
3606        offset += size;
3607    }
3608    let r = reversed.to_ascii_lowercase();
3609    Some(format!(
3610        "{{{}-{}-{}-{}-{}}}",
3611        &r[0..8],
3612        &r[8..12],
3613        &r[12..16],
3614        &r[16..20],
3615        &r[20..32]
3616    ))
3617}
3618
3619#[cfg(not(windows))]
3620fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3621    Ok(Vec::new())
3622}
3623
3624#[cfg(windows)]
3625fn collect_uninstall_view(
3626    packages: &mut Vec<InstalledPackage>,
3627    seen: &mut BTreeSet<String>,
3628    upgrade_code_map: &std::collections::HashMap<String, String>,
3629    root: RegKey,
3630    scope: &str,
3631    arch: &str,
3632    flags: u32,
3633) -> Result<()> {
3634    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
3635
3636    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
3637        Ok(key) => key,
3638        Err(_) => return Ok(()),
3639    };
3640
3641    for key_name in uninstall.enum_keys().flatten() {
3642        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
3643            Ok(key) => key,
3644            Err(_) => continue,
3645        };
3646        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
3647        {
3648            continue;
3649        }
3650
3651        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
3652            continue;
3653        };
3654
3655        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
3656        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
3657        let publisher = read_reg_string(&subkey, "Publisher");
3658        let install_location = read_reg_string(&subkey, "InstallLocation");
3659        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
3660            .into_iter()
3661            .collect::<Vec<_>>();
3662        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
3663        if product_codes.is_empty() && looks_like_product_code(&key_name) {
3664            product_codes.push(key_name.to_ascii_lowercase());
3665        }
3666        let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
3667        if upgrade_codes.is_empty() {
3668            // ARP rarely exposes UpgradeCode directly. Recover it from the MSI
3669            // Installer registry hive so identity correlation can fall back to
3670            // upgradecodes2 when productcodes2 picks the wrong sibling.
3671            for code in &product_codes {
3672                if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
3673                    upgrade_codes.push(uc.clone());
3674                    break;
3675                }
3676            }
3677        }
3678        let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
3679            .as_deref()
3680            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
3681        {
3682            // Honor the WinGet ARP signal so uninstall flows through
3683            // uninstall_portable (clean dir + registry removal) instead of
3684            // delegating the WinGet portable UninstallString to a winget
3685            // subprocess that only fully cleans up entries winget itself
3686            // installed.
3687            Some("portable".to_owned())
3688        } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
3689            Some("msi".to_owned())
3690        } else if key_name.starts_with("MSIX\\") {
3691            Some("msix".to_owned())
3692        } else {
3693            Some("exe".to_owned())
3694        };
3695
3696        let dedupe_key = format!(
3697            "{}|{}|{}|{}",
3698            local_id,
3699            name.to_ascii_lowercase(),
3700            installed_version.to_ascii_lowercase(),
3701            publisher.clone().unwrap_or_default().to_ascii_lowercase()
3702        );
3703        if !seen.insert(dedupe_key) {
3704            continue;
3705        }
3706
3707        packages.push(InstalledPackage {
3708            name,
3709            local_id,
3710            installed_version,
3711            publisher,
3712            scope: Some(scope.to_owned()),
3713            installer_category,
3714            install_location,
3715            package_family_names,
3716            product_codes,
3717            upgrade_codes,
3718            correlated: None,
3719            installed_version_canonical: false,
3720            correlated_requires_explicit_upgrade: false,
3721            correlated_lacks_compatible_installer: false,
3722        });
3723    }
3724
3725    Ok(())
3726}
3727
3728#[cfg(windows)]
3729fn collect_appmodel_packages(
3730    packages: &mut Vec<InstalledPackage>,
3731    seen: &mut BTreeSet<String>,
3732    root: RegKey,
3733    scope: &str,
3734    flags: u32,
3735) -> Result<()> {
3736    const APPMODEL_PACKAGES_PATH: &str =
3737        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
3738
3739    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
3740        Ok(key) => key,
3741        Err(_) => return Ok(()),
3742    };
3743
3744    for key_name in appmodel.enum_keys().flatten() {
3745        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
3746            Ok(key) => key,
3747            Err(_) => continue,
3748        };
3749
3750        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
3751            continue;
3752        };
3753        let install_location = read_reg_string(&subkey, "PackageRootFolder");
3754        if install_location.as_deref().is_some_and(is_windows_system_path) {
3755            continue;
3756        }
3757
3758        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
3759            continue;
3760        };
3761
3762        let local_id = format!(r"MSIX\{key_name}");
3763        let dedupe_key = format!(
3764            "{}|{}|{}",
3765            local_id,
3766            name.to_ascii_lowercase(),
3767            metadata.version.to_ascii_lowercase()
3768        );
3769        if !seen.insert(dedupe_key) {
3770            continue;
3771        }
3772
3773        packages.push(InstalledPackage {
3774            name,
3775            local_id,
3776            installed_version: metadata.version,
3777            publisher: None,
3778            scope: Some(scope.to_owned()),
3779            installer_category: Some("msix".to_owned()),
3780            install_location,
3781            package_family_names: vec![metadata.family_name],
3782            product_codes: Vec::new(),
3783            upgrade_codes: Vec::new(),
3784            correlated: None,
3785            installed_version_canonical: false,
3786            correlated_requires_explicit_upgrade: false,
3787            correlated_lacks_compatible_installer: false,
3788        });
3789    }
3790
3791    Ok(())
3792}
3793
3794#[cfg(windows)]
3795struct ParsedMsixPackageFullName {
3796    version: String,
3797    family_name: String,
3798}
3799
3800#[cfg(windows)]
3801fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
3802    let segments = value.split('_').collect::<Vec<_>>();
3803    if segments.len() < 5 {
3804        return None;
3805    }
3806
3807    let name = segments[..segments.len() - 4].join("_");
3808    if name.is_empty() {
3809        return None;
3810    }
3811
3812    let version = segments[segments.len() - 4].trim();
3813    let resource_id = segments[segments.len() - 2].trim();
3814    let publisher_id = segments[segments.len() - 1].trim();
3815    if version.is_empty() || publisher_id.is_empty() {
3816        return None;
3817    }
3818
3819    let family_name = if resource_id.is_empty() {
3820        format!("{name}_{publisher_id}")
3821    } else {
3822        format!("{name}_{resource_id}_{publisher_id}")
3823    };
3824
3825    Some(ParsedMsixPackageFullName {
3826        version: version.to_owned(),
3827        family_name,
3828    })
3829}
3830
3831#[cfg(windows)]
3832fn is_windows_system_path(path: &str) -> bool {
3833    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
3834}
3835
3836#[cfg(windows)]
3837fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
3838    key.get_value::<String, _>(value_name)
3839        .ok()
3840        .map(|value| value.trim().to_owned())
3841        .filter(|value| !value.is_empty())
3842}
3843
3844#[cfg(windows)]
3845fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
3846    key.get_value::<u32, _>(value_name).ok()
3847}
3848
3849#[cfg(windows)]
3850fn looks_like_product_code(value: &str) -> bool {
3851    value.starts_with('{') && value.ends_with('}')
3852}
3853
3854fn ensure_app_dirs(app_root: &Path) -> Result<()> {
3855    fs::create_dir_all(app_root).context("failed to create app root directory")?;
3856    if uses_packaged_layout(app_root) {
3857        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
3858        let user_sources_path = packaged_user_sources_path(app_root)?;
3859        if let Some(parent) = user_sources_path.parent() {
3860            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
3861        }
3862    } else {
3863        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
3864    }
3865    Ok(())
3866}
3867
3868fn default_app_root() -> Result<PathBuf> {
3869    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
3870        return Ok(PathBuf::from(app_root));
3871    }
3872
3873    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
3874
3875    #[cfg(windows)]
3876    {
3877        if has_current_package_identity() {
3878            return Ok(local_app_data
3879                .join("Packages")
3880                .join(PACKAGED_FAMILY_NAME)
3881                .join("LocalState"));
3882        }
3883        Ok(local_app_data.join("Devolutions").join("Pinget"))
3884    }
3885
3886    #[cfg(not(windows))]
3887    {
3888        Ok(local_app_data.join("pinget"))
3889    }
3890}
3891
3892#[cfg(windows)]
3893fn has_current_package_identity() -> bool {
3894    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
3895    // process has no AppX package identity.
3896    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
3897
3898    let mut length: u32 = 0;
3899    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
3900    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
3901    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
3902    rc != APPMODEL_ERROR_NO_PACKAGE
3903}
3904
3905fn store_path(app_root: &Path) -> PathBuf {
3906    app_root.join("sources.json")
3907}
3908
3909fn user_settings_path(app_root: &Path) -> PathBuf {
3910    if uses_packaged_layout(app_root) {
3911        app_root.join("settings.json")
3912    } else {
3913        app_root.join("user-settings.json")
3914    }
3915}
3916
3917fn admin_settings_path(app_root: &Path) -> PathBuf {
3918    app_root.join("admin-settings.json")
3919}
3920
3921fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
3922    if uses_packaged_layout(app_root) {
3923        return app_root
3924            .join(packaged_source_type(source.kind))
3925            .join(sanitize_path_segment(&source.identifier));
3926    }
3927
3928    app_root.join("sources").join(sanitize_path_segment(&source.name))
3929}
3930
3931fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3932    source_state_dir(app_root, source).join("source.msix")
3933}
3934
3935fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3936    source_state_dir(app_root, source).join("index.db")
3937}
3938
3939fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3940    source_state_dir(app_root, source).join("rest-information.json")
3941}
3942
3943fn rest_manifest_cache_path_with_root(
3944    app_root: &Path,
3945    source: &SourceRecord,
3946    package_id: &str,
3947    version: &str,
3948    channel: &str,
3949) -> PathBuf {
3950    let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
3951    let digest = sha256_hex(key.as_bytes());
3952    cache_root(app_root)
3953        .join("REST_M")
3954        .join(sanitize_path_segment(&source.identifier))
3955        .join(format!("{digest}.json"))
3956}
3957
3958fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
3959    cache_root(app_root)
3960        .join(bucket)
3961        .join(sanitize_path_segment(identifier))
3962}
3963
3964fn load_store(app_root: &Path) -> Result<SourceStore> {
3965    if uses_packaged_layout(app_root)
3966        && let Some(store) = load_packaged_store(app_root)?
3967    {
3968        return Ok(store);
3969    }
3970
3971    let path = store_path(app_root);
3972    if !path.exists() {
3973        let store = SourceStore::default();
3974        save_store(app_root, &store)?;
3975        return Ok(store);
3976    }
3977
3978    let bytes = fs::read(path).context("failed to read source store")?;
3979    serde_json::from_slice(&bytes).context("failed to parse source store")
3980}
3981
3982fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
3983    if uses_system_winget_source_commands(app_root) {
3984        bail!("System WinGet source settings must be modified through winget.");
3985    }
3986
3987    if uses_packaged_layout(app_root) {
3988        return save_packaged_store(app_root, store);
3989    }
3990
3991    write_json(store_path(app_root), store)
3992}
3993
3994fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
3995    let user_sources_path = packaged_user_sources_path(app_root)?;
3996    let metadata_path = app_root.join("sources_metadata");
3997    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
3998    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
3999    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
4000}
4001
4002fn load_packaged_store_from_streams(
4003    user_sources_yaml: Option<&str>,
4004    metadata_yaml: Option<&str>,
4005) -> Result<Option<SourceStore>> {
4006    if is_secure_settings_stub(user_sources_yaml) {
4007        return load_system_winget_source_store().map(Some);
4008    }
4009
4010    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
4011}
4012
4013fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4014    let user_sources_path = packaged_user_sources_path(app_root)?;
4015    if let Some(parent) = user_sources_path.parent() {
4016        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
4017    }
4018
4019    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
4020        .context("failed to write packaged user_sources stream")?;
4021    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4022        .context("failed to write packaged source metadata")?;
4023    Ok(())
4024}
4025
4026fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4027    if !uses_packaged_layout(app_root) {
4028        return false;
4029    }
4030
4031    #[cfg(windows)]
4032    {
4033        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4034            return false;
4035        };
4036
4037        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4038        is_secure_settings_stub(user_sources_yaml.as_deref())
4039    }
4040
4041    #[cfg(not(windows))]
4042    {
4043        false
4044    }
4045}
4046
4047fn is_secure_settings_stub(value: Option<&str>) -> bool {
4048    value
4049        .map(str::trim_start)
4050        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4051}
4052
4053fn load_system_winget_source_store() -> Result<SourceStore> {
4054    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4055    Ok(SourceStore {
4056        sources: parse_system_winget_source_export(&result.stdout)?,
4057    })
4058}
4059
4060fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4061    run_checked_winget_source_command(
4062        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4063        &format!("add WinGet source '{name}'"),
4064    )?;
4065    Ok(())
4066}
4067
4068fn system_winget_remove_source(name: &str) -> Result<()> {
4069    run_checked_winget_source_command(
4070        vec![
4071            "source".to_owned(),
4072            "remove".to_owned(),
4073            "--name".to_owned(),
4074            name.to_owned(),
4075            "--disable-interactivity".to_owned(),
4076        ],
4077        &format!("remove WinGet source '{name}'"),
4078    )?;
4079    Ok(())
4080}
4081
4082fn system_winget_reset_source(name: &str) -> Result<()> {
4083    run_checked_winget_source_command(
4084        vec![
4085            "source".to_owned(),
4086            "reset".to_owned(),
4087            "--name".to_owned(),
4088            name.to_owned(),
4089            "--force".to_owned(),
4090            "--disable-interactivity".to_owned(),
4091        ],
4092        &format!("reset WinGet source '{name}'"),
4093    )?;
4094    Ok(())
4095}
4096
4097fn system_winget_reset_sources() -> Result<()> {
4098    run_checked_winget_source_command(
4099        vec![
4100            "source".to_owned(),
4101            "reset".to_owned(),
4102            "--force".to_owned(),
4103            "--disable-interactivity".to_owned(),
4104        ],
4105        "reset WinGet sources",
4106    )?;
4107    Ok(())
4108}
4109
4110fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4111    let mut args = vec!["source".to_owned(), "update".to_owned()];
4112    if let Some(name) = name {
4113        args.push("--name".to_owned());
4114        args.push(name.to_owned());
4115    }
4116
4117    args.push("--disable-interactivity".to_owned());
4118    let result = run_checked_winget_source_command(
4119        args,
4120        if name.is_some() {
4121            "update WinGet source"
4122        } else {
4123            "update WinGet sources"
4124        },
4125    )?;
4126    let detail = result.stdout.trim();
4127    Ok(if detail.is_empty() {
4128        "Done".to_owned()
4129    } else {
4130        detail.to_owned()
4131    })
4132}
4133
4134fn build_winget_source_export_arguments() -> Vec<String> {
4135    vec![
4136        "source".to_owned(),
4137        "export".to_owned(),
4138        "--disable-interactivity".to_owned(),
4139    ]
4140}
4141
4142fn build_winget_source_add_arguments(
4143    name: &str,
4144    arg: &str,
4145    kind: SourceKind,
4146    trust_level: &str,
4147    explicit: bool,
4148) -> Vec<String> {
4149    let mut args = vec![
4150        "source".to_owned(),
4151        "add".to_owned(),
4152        "--name".to_owned(),
4153        name.to_owned(),
4154        "--arg".to_owned(),
4155        arg.to_owned(),
4156        "--type".to_owned(),
4157        packaged_source_type(kind).to_owned(),
4158        "--disable-interactivity".to_owned(),
4159    ];
4160
4161    if !trust_level.trim().is_empty() {
4162        args.push("--trust-level".to_owned());
4163        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4164            "trusted".to_owned()
4165        } else {
4166            "none".to_owned()
4167        });
4168    }
4169
4170    if explicit {
4171        args.push("--explicit".to_owned());
4172    }
4173
4174    args
4175}
4176
4177#[derive(Debug)]
4178struct WingetSourceCommandResult {
4179    exit_code: i32,
4180    stdout: String,
4181    stderr: String,
4182}
4183
4184type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4185
4186static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4187    RwLock::new(run_winget_source_command_process);
4188
4189fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4190    let result = run_winget_source_command(&args)?;
4191    if result.exit_code == 0 {
4192        return Ok(result);
4193    }
4194
4195    let detail = [result.stderr.trim(), result.stdout.trim()]
4196        .into_iter()
4197        .filter(|value| !value.is_empty())
4198        .collect::<Vec<_>>()
4199        .join("\n");
4200    if detail.is_empty() {
4201        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4202    }
4203
4204    bail!(
4205        "Failed to {action}; winget exited with code {}:\n{}",
4206        result.exit_code,
4207        detail
4208    );
4209}
4210
4211fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4212    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4213        .read()
4214        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4215    runner(args)
4216}
4217
4218fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4219    use std::process::Command;
4220
4221    let output = Command::new("winget")
4222        .args(args)
4223        .output()
4224        .context("failed to run winget source command")?;
4225
4226    Ok(WingetSourceCommandResult {
4227        exit_code: output.status.code().unwrap_or(-1),
4228        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4229        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4230    })
4231}
4232
4233fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4234    let trimmed = output.trim();
4235    if trimmed.is_empty() {
4236        return Ok(Vec::new());
4237    }
4238
4239    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4240        && let Some(sources) = parse_system_winget_source_export_value(&value)?
4241    {
4242        return Ok(order_sources(sources));
4243    }
4244
4245    let mut sources = Vec::new();
4246    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4247        if line.is_empty() {
4248            continue;
4249        }
4250
4251        let value: JsonValue = serde_json::from_str(line)
4252            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4253        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4254            bail!("WinGet source export returned a non-source line: {line}");
4255        };
4256        sources.append(&mut parsed);
4257    }
4258
4259    Ok(order_sources(sources))
4260}
4261
4262fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4263    match value {
4264        JsonValue::Array(items) => items
4265            .iter()
4266            .map(parse_system_winget_source_record)
4267            .collect::<Result<Vec<_>>>()
4268            .map(Some),
4269        JsonValue::Object(object) => {
4270            if let Some(JsonValue::Array(items)) = object.get("Sources") {
4271                items
4272                    .iter()
4273                    .map(parse_system_winget_source_record)
4274                    .collect::<Result<Vec<_>>>()
4275                    .map(Some)
4276            } else if object.contains_key("Name") {
4277                Ok(Some(vec![parse_system_winget_source_record(value)?]))
4278            } else {
4279                Ok(None)
4280            }
4281        }
4282        _ => Ok(None),
4283    }
4284}
4285
4286fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4287    let object = value
4288        .as_object()
4289        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4290    let name = required_json_string(object, "Name")?;
4291    let source_type = required_json_string(object, "Type")?;
4292    let arg = required_json_string(object, "Arg")?;
4293    let kind = parse_system_winget_source_kind(&source_type)?;
4294    let identifier = optional_json_string(object, "Identifier")
4295        .or_else(|| optional_json_string(object, "Data"))
4296        .unwrap_or_else(|| name.clone());
4297
4298    Ok(SourceRecord {
4299        name,
4300        kind,
4301        arg,
4302        identifier,
4303        trust_level: parse_system_winget_trust_level(object),
4304        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4305        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4306        last_update: None,
4307        source_version: None,
4308    })
4309}
4310
4311fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4312    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4313}
4314
4315fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4316    match object.get(key)? {
4317        JsonValue::String(value) => Some(value.clone()),
4318        JsonValue::Number(value) => Some(value.to_string()),
4319        _ => None,
4320    }
4321}
4322
4323fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4324    match object.get(key)? {
4325        JsonValue::Bool(value) => Some(*value),
4326        JsonValue::String(value) => value.parse().ok(),
4327        _ => None,
4328    }
4329}
4330
4331fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4332    match object.get(key)? {
4333        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4334        JsonValue::String(value) => value.parse().ok(),
4335        _ => None,
4336    }
4337}
4338
4339fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4340    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4341        Ok(SourceKind::PreIndexed)
4342    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4343        Ok(SourceKind::Rest)
4344    } else {
4345        bail!("Unsupported WinGet source type '{value}'.")
4346    }
4347}
4348
4349fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4350    let Some(value) = object.get("TrustLevel") else {
4351        return "None".to_owned();
4352    };
4353
4354    match value {
4355        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4356        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4357        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4358        _ => "None".to_owned(),
4359    }
4360}
4361
4362fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4363    value
4364        .as_str()
4365        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4366}
4367
4368fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4369    sources.sort_by(|left, right| {
4370        left.explicit
4371            .cmp(&right.explicit)
4372            .then_with(|| right.priority.cmp(&left.priority))
4373            .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4374    });
4375    sources
4376}
4377
4378fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4379    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4380        return None;
4381    }
4382
4383    let mut sources = SourceStore::default().sources;
4384    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4385        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4386            continue;
4387        };
4388
4389        if yaml_bool(entry.get("IsTombstone")) {
4390            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4391            continue;
4392        }
4393
4394        let Some(mapped) = map_packaged_source_entry(&entry) else {
4395            continue;
4396        };
4397
4398        if let Some(existing) = sources
4399            .iter_mut()
4400            .find(|source| source.name.eq_ignore_ascii_case(&name))
4401        {
4402            *existing = mapped;
4403        } else {
4404            sources.push(mapped);
4405        }
4406    }
4407
4408    for entry in parse_packaged_yaml_entries(metadata_yaml) {
4409        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4410            continue;
4411        };
4412
4413        let Some(source) = sources
4414            .iter_mut()
4415            .find(|source| source.name.eq_ignore_ascii_case(&name))
4416        else {
4417            continue;
4418        };
4419
4420        if let Some(last_update) = entry
4421            .get("LastUpdate")
4422            .and_then(|value| value.as_deref())
4423            .and_then(parse_packaged_datetime)
4424        {
4425            source.last_update = Some(last_update);
4426        }
4427
4428        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4429            && !source_version.is_empty()
4430        {
4431            source.source_version = Some(source_version);
4432        }
4433    }
4434
4435    Some(SourceStore { sources })
4436}
4437
4438fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
4439    let Some(yaml) = yaml else {
4440        return Vec::new();
4441    };
4442
4443    let mut entries = Vec::new();
4444    let mut current: Option<BTreeMap<String, Option<String>>> = None;
4445    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
4446        let line = raw_line.trim_end();
4447        let trimmed = line.trim();
4448        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
4449            continue;
4450        }
4451
4452        if let Some(remainder) = trimmed.strip_prefix("- ") {
4453            if let Some(entry) = current.take() {
4454                entries.push(entry);
4455            }
4456
4457            let mut entry = BTreeMap::new();
4458            add_packaged_yaml_key_value(&mut entry, remainder);
4459            current = Some(entry);
4460            continue;
4461        }
4462
4463        if let Some(entry) = current.as_mut() {
4464            add_packaged_yaml_key_value(entry, trimmed);
4465        }
4466    }
4467
4468    if let Some(entry) = current {
4469        entries.push(entry);
4470    }
4471
4472    entries
4473}
4474
4475fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
4476    let Some((key, value)) = line.split_once(':') else {
4477        return;
4478    };
4479
4480    entry.insert(
4481        key.trim().to_owned(),
4482        Some(unquote_yaml_scalar(value.trim()).to_owned()),
4483    );
4484}
4485
4486fn unquote_yaml_scalar(value: &str) -> &str {
4487    if value.len() >= 2 {
4488        let bytes = value.as_bytes();
4489        let first = bytes[0] as char;
4490        let last = bytes[value.len() - 1] as char;
4491        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
4492            return &value[1..value.len() - 1];
4493        }
4494    }
4495
4496    value
4497}
4498
4499fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
4500    let name = entry.get("Name").and_then(|value| value.clone())?;
4501    let source_type = entry.get("Type").and_then(|value| value.clone())?;
4502    let arg = entry.get("Arg").and_then(|value| value.clone())?;
4503    let kind = parse_packaged_source_kind(&source_type)?;
4504
4505    Some(SourceRecord {
4506        name: name.clone(),
4507        kind,
4508        arg,
4509        identifier: entry
4510            .get("Identifier")
4511            .and_then(|value| value.clone())
4512            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
4513            .unwrap_or(name),
4514        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
4515        explicit: yaml_bool(entry.get("Explicit")),
4516        priority: entry
4517            .get("Priority")
4518            .and_then(|value| value.as_deref())
4519            .and_then(|value| value.parse::<i32>().ok())
4520            .unwrap_or_default(),
4521        last_update: None,
4522        source_version: None,
4523    })
4524}
4525
4526fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
4527    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4528        Some(SourceKind::PreIndexed)
4529    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4530        Some(SourceKind::Rest)
4531    } else {
4532        None
4533    }
4534}
4535
4536fn parse_packaged_trust_level(value: Option<&str>) -> String {
4537    match value {
4538        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
4539        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
4540        _ => "None".to_owned(),
4541    }
4542}
4543
4544fn yaml_bool(value: Option<&Option<String>>) -> bool {
4545    value
4546        .and_then(|raw| raw.as_deref())
4547        .and_then(|raw| raw.parse::<bool>().ok())
4548        .unwrap_or(false)
4549}
4550
4551fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
4552    if let Ok(unix_seconds) = value.parse::<i64>() {
4553        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
4554    }
4555
4556    DateTime::parse_from_rfc3339(value)
4557        .ok()
4558        .map(|value| value.with_timezone(&Utc))
4559}
4560
4561fn render_packaged_sources_yaml(store: &SourceStore) -> String {
4562    let mut lines = vec!["Sources:".to_owned()];
4563    for source in &store.sources {
4564        lines.push(format!("  - Name: {}", source.name));
4565        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
4566        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
4567        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
4568        lines.push(format!(
4569            "    TrustLevel: {}",
4570            if source.trust_level.eq_ignore_ascii_case("Trusted") {
4571                1
4572            } else {
4573                0
4574            }
4575        ));
4576        lines.push(format!("    Explicit: {}", source.explicit));
4577        lines.push(format!("    Priority: {}", source.priority));
4578        lines.push("    IsTombstone: false".to_owned());
4579    }
4580    lines.push(String::new());
4581    lines.join("\n")
4582}
4583
4584fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
4585    let mut lines = vec!["Sources:".to_owned()];
4586    for source in &store.sources {
4587        if source.last_update.is_none() && source.source_version.is_none() {
4588            continue;
4589        }
4590
4591        lines.push(format!("  - Name: {}", source.name));
4592        if let Some(last_update) = source.last_update {
4593            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
4594        }
4595        if let Some(source_version) = &source.source_version {
4596            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
4597        }
4598    }
4599    lines.push(String::new());
4600    lines.join("\n")
4601}
4602
4603fn yaml_scalar(value: &str) -> String {
4604    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
4605        format!("\"{}\"", value.replace('"', "\\\""))
4606    } else {
4607        value.to_owned()
4608    }
4609}
4610
4611fn uses_packaged_layout(app_root: &Path) -> bool {
4612    #[cfg(windows)]
4613    {
4614        try_get_packaged_local_state_root(app_root).is_some()
4615    }
4616
4617    #[cfg(not(windows))]
4618    {
4619        let _ = app_root;
4620        false
4621    }
4622}
4623
4624#[cfg(windows)]
4625fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
4626    let local_state = app_root.file_name()?.to_string_lossy();
4627    if !local_state.eq_ignore_ascii_case("LocalState") {
4628        return None;
4629    }
4630
4631    let family = app_root.parent()?.file_name()?.to_string_lossy();
4632    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
4633        return None;
4634    }
4635
4636    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
4637    if !packages.eq_ignore_ascii_case("Packages") {
4638        return None;
4639    }
4640
4641    Some(app_root.to_path_buf())
4642}
4643
4644#[cfg(windows)]
4645fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
4646    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
4647    Ok(packaged_secure_settings_root()?.join("user_sources"))
4648}
4649
4650#[cfg(not(windows))]
4651fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
4652    bail!("packaged WinGet source streams are only available on Windows")
4653}
4654
4655#[cfg(windows)]
4656fn packaged_secure_settings_root() -> Result<PathBuf> {
4657    let program_data = std::env::var_os("PROGRAMDATA")
4658        .map(PathBuf::from)
4659        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
4660    Ok(program_data
4661        .join("Microsoft")
4662        .join("WinGet")
4663        .join(current_user_sid()?)
4664        .join("settings")
4665        .join("pkg")
4666        .join(PACKAGED_NAME))
4667}
4668
4669#[cfg(windows)]
4670#[allow(clippy::multiple_unsafe_ops_per_block)]
4671fn current_user_sid() -> Result<String> {
4672    fn close_token(token: HANDLE) {
4673        if !token.is_null() {
4674            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
4675            unsafe {
4676                CloseHandle(token);
4677            }
4678        }
4679    }
4680
4681    let mut token: HANDLE = std::ptr::null_mut();
4682    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
4683    let current_process = unsafe { GetCurrentProcess() };
4684    // SAFETY: token points to valid storage for the process token handle on success.
4685    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
4686        bail!("failed to open current process token")
4687    }
4688
4689    let mut length = 0;
4690    // SAFETY: The null buffer call asks Windows for the required buffer length.
4691    unsafe {
4692        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
4693    }
4694
4695    let element_size = size_of::<TOKEN_USER>();
4696    let element_count = (length as usize).div_ceil(element_size);
4697    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
4698    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
4699    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
4700        close_token(token);
4701        bail!("failed to get current user token information")
4702    }
4703
4704    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
4705    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
4706    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
4707    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
4708    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
4709        close_token(token);
4710        bail!("failed to convert current user SID")
4711    }
4712
4713    let mut len = 0usize;
4714    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
4715    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
4716        len += 1;
4717    }
4718
4719    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
4720    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
4721    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
4722    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
4723    unsafe {
4724        LocalFree(sid_ptr.cast());
4725    }
4726    close_token(token);
4727    Ok(sid)
4728}
4729
4730fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
4731    app_root.join("Microsoft").join("Windows Package Manager")
4732}
4733
4734fn cache_root(app_root: &Path) -> PathBuf {
4735    if uses_packaged_layout(app_root) {
4736        packaged_file_cache_root(app_root)
4737    } else {
4738        default_cache_root_fallback()
4739    }
4740}
4741
4742fn default_cache_root_fallback() -> PathBuf {
4743    std::env::temp_dir().join("cache")
4744}
4745
4746fn packaged_source_type(kind: SourceKind) -> &'static str {
4747    match kind {
4748        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
4749        SourceKind::Rest => "Microsoft.Rest",
4750    }
4751}
4752
4753fn sanitize_path_segment(value: &str) -> String {
4754    value
4755        .chars()
4756        .map(|ch| match ch {
4757            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
4758            _ => ch,
4759        })
4760        .collect()
4761}
4762
4763fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
4764    if !path.exists() {
4765        return Ok(JsonMap::new());
4766    }
4767
4768    let bytes = fs::read(path).context("failed to read JSON file")?;
4769    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
4770    Ok(match value {
4771        JsonValue::Object(object) => object,
4772        _ => JsonMap::new(),
4773    })
4774}
4775
4776fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
4777    write_json(path, value)
4778}
4779
4780fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
4781    if let Some(parent) = path.parent() {
4782        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
4783    }
4784
4785    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
4786    fs::write(path, bytes).context("failed to write JSON file")
4787}
4788
4789fn merge_json_objects(
4790    current: &JsonMap<String, JsonValue>,
4791    update: &JsonMap<String, JsonValue>,
4792) -> JsonMap<String, JsonValue> {
4793    let mut merged = current.clone();
4794    for (key, value) in update {
4795        match (merged.get(key), value) {
4796            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
4797                merged.insert(
4798                    key.clone(),
4799                    JsonValue::Object(merge_json_objects(current_object, update_object)),
4800                );
4801            }
4802            _ => {
4803                merged.insert(key.clone(), value.clone());
4804            }
4805        }
4806    }
4807
4808    merged
4809}
4810
4811fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
4812    match expected {
4813        JsonValue::Object(expected_object) => match current {
4814            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
4815                current_object
4816                    .get(key)
4817                    .is_some_and(|current_child| json_contains(current_child, expected_child))
4818            }),
4819            _ => false,
4820        },
4821        JsonValue::Array(expected_array) => match current {
4822            JsonValue::Array(current_array) => {
4823                current_array.len() == expected_array.len()
4824                    && current_array
4825                        .iter()
4826                        .zip(expected_array.iter())
4827                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
4828            }
4829            _ => false,
4830        },
4831        _ => current == expected,
4832    }
4833}
4834
4835fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
4836    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
4837        "none" | "default" => Ok("None".to_owned()),
4838        "trusted" => Ok("Trusted".to_owned()),
4839        other => bail!("unsupported source trust level: {other}"),
4840    }
4841}
4842
4843fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
4844    SUPPORTED_ADMIN_SETTINGS
4845        .iter()
4846        .copied()
4847        .find(|setting| setting.eq_ignore_ascii_case(name))
4848        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
4849}
4850
4851fn default_source_trust_level() -> String {
4852    "None".to_owned()
4853}
4854
4855fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
4856where
4857    F: FnMut(&SqlRow<'_>) -> Result<T>,
4858{
4859    let mut statement = connection
4860        .prepare(sql)
4861        .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4862    let mut rows = statement
4863        .query(params_from_iter(params))
4864        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4865    let mut result = Vec::new();
4866
4867    while let Some(row) = rows.next().context("failed to read SQL row")? {
4868        result.push(map(row)?);
4869    }
4870
4871    Ok(result)
4872}
4873
4874fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
4875where
4876    F: FnOnce(&SqlRow<'_>) -> Result<T>,
4877{
4878    let mut statement = connection
4879        .prepare(sql)
4880        .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4881    let mut rows = statement
4882        .query(params_from_iter(params))
4883        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4884
4885    match rows.next().context("failed to read SQL row")? {
4886        Some(row) => Ok(Some(map(row)?)),
4887        None => Ok(None),
4888    }
4889}
4890
4891fn row_ref<'a>(row: &'a SqlRow<'_>, index: usize) -> Result<ValueRef<'a>> {
4892    row.get_ref(index)
4893        .with_context(|| format!("failed to read SQL column {index}"))
4894}
4895
4896fn row_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4897    match row_ref(row, index)? {
4898        ValueRef::Text(value) => std::str::from_utf8(value)
4899            .context("SQL text column was not valid UTF-8")
4900            .map(str::to_owned),
4901        ValueRef::Integer(value) => Ok(value.to_string()),
4902        ValueRef::Real(value) => Ok(value.to_string()),
4903        ValueRef::Blob(value) => String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8"),
4904        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4905    }
4906}
4907
4908fn row_opt_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4909    match row_ref(row, index)? {
4910        ValueRef::Null => Ok(None),
4911        ValueRef::Text(value) => Ok(Some(
4912            std::str::from_utf8(value)
4913                .context("SQL text column was not valid UTF-8")?
4914                .to_owned(),
4915        )),
4916        ValueRef::Integer(value) => Ok(Some(value.to_string())),
4917        ValueRef::Real(value) => Ok(Some(value.to_string())),
4918        ValueRef::Blob(value) => Ok(Some(
4919            String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8")?,
4920        )),
4921    }
4922}
4923
4924fn row_i64(row: &SqlRow<'_>, index: usize) -> Result<i64> {
4925    match row_ref(row, index)? {
4926        ValueRef::Integer(value) => Ok(value),
4927        ValueRef::Text(value) => std::str::from_utf8(value)
4928            .context("SQL text column was not valid UTF-8")?
4929            .parse::<i64>()
4930            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
4931        ValueRef::Real(value) => f64_to_i64_checked(value, index),
4932        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4933        ValueRef::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
4934    }
4935}
4936
4937fn row_hex_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4938    match row_ref(row, index)? {
4939        ValueRef::Blob(value) => Ok(bytes_to_hex(value)),
4940        ValueRef::Text(value) => Ok(std::str::from_utf8(value)
4941            .context("SQL text column was not valid UTF-8")?
4942            .to_ascii_lowercase()),
4943        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4944        ValueRef::Integer(value) => Ok(format!("{value:x}")),
4945        ValueRef::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
4946    }
4947}
4948
4949fn row_opt_hex_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4950    match row_ref(row, index)? {
4951        ValueRef::Null => Ok(None),
4952        ValueRef::Blob(value) => Ok(Some(bytes_to_hex(value))),
4953        ValueRef::Text(value) => Ok(Some(
4954            std::str::from_utf8(value)
4955                .context("SQL text column was not valid UTF-8")?
4956                .to_ascii_lowercase(),
4957        )),
4958        ValueRef::Integer(value) => Ok(Some(format!("{value:x}"))),
4959        ValueRef::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
4960    }
4961}
4962
4963fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
4964    if !value.is_finite() {
4965        bail!("SQL column {index} contained a non-finite floating-point value");
4966    }
4967
4968    if value.fract() != 0.0 {
4969        bail!("SQL column {index} contained a non-integer floating-point value");
4970    }
4971
4972    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
4973        bail!("SQL column {index} contained a floating-point value outside the i64 range");
4974    }
4975
4976    format!("{value:.0}")
4977        .parse::<i64>()
4978        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
4979}
4980
4981fn bytes_to_hex(bytes: &[u8]) -> String {
4982    let mut result = String::with_capacity(bytes.len() * 2);
4983    for byte in bytes {
4984        result.push_str(&format!("{byte:02x}"));
4985    }
4986    result
4987}
4988
4989fn query_v2_matches(
4990    connection: &Connection,
4991    query: &PackageQuery,
4992    semantics: SearchSemantics,
4993) -> Result<(Vec<V2SearchRow>, bool)> {
4994    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
4995    let limit = source_fetch_results(query, semantics);
4996    let sql = format!(
4997        "SELECT rowid, id, name, moniker, latest_version, hash \
4998         FROM packages WHERE {where_clause} LIMIT {}",
4999        limit + 1
5000    );
5001    let mut rows = query_rows(
5002        connection,
5003        &sql,
5004        params.into_iter().map(SqlValue::Text).collect(),
5005        |row| {
5006            Ok(V2SearchRow {
5007                package_rowid: row_i64(row, 0)?,
5008                id: row_string(row, 1)?,
5009                name: row_string(row, 2)?,
5010                moniker: row_opt_string(row, 3)?,
5011                version: row_string(row, 4)?,
5012                package_hash: row_hex_string(row, 5)?,
5013                match_criteria: None,
5014            })
5015        },
5016    )?;
5017    for row in &mut rows {
5018        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
5019    }
5020    let truncated = rows.len() > limit;
5021    rows.truncate(limit);
5022    Ok((rows, truncated))
5023}
5024
5025fn query_v1_matches(
5026    connection: &Connection,
5027    query: &PackageQuery,
5028    semantics: SearchSemantics,
5029) -> Result<(Vec<V1SearchRow>, bool)> {
5030    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5031    let limit = source_fetch_results(query, semantics);
5032    let sql = format!(
5033        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5034         FROM manifest \
5035         JOIN ids ON manifest.id = ids.rowid \
5036         JOIN names ON manifest.name = names.rowid \
5037         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5038         JOIN versions ON manifest.version = versions.rowid \
5039         JOIN channels ON manifest.channel = channels.rowid \
5040         WHERE {where_clause} LIMIT {}",
5041        limit + 1
5042    );
5043    let mut rows = query_rows(
5044        connection,
5045        &sql,
5046        params.into_iter().map(SqlValue::Text).collect(),
5047        |row| {
5048            Ok(V1SearchRow {
5049                manifest_rowid: row_i64(row, 0)?,
5050                package_rowid: row_i64(row, 1)?,
5051                version: row_string(row, 2)?,
5052                channel: row_string(row, 3)?,
5053                id: row_string(row, 4)?,
5054                name: row_string(row, 5)?,
5055                moniker: row_opt_string(row, 6)?,
5056                match_criteria: None,
5057            })
5058        },
5059    )?;
5060    for row in &mut rows {
5061        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5062    }
5063    let truncated = rows.len() > limit;
5064    rows.truncate(limit);
5065    Ok((rows, truncated))
5066}
5067
5068fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5069    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5070
5071    for row in rows {
5072        match grouped.get(&row.package_rowid) {
5073            Some(existing)
5074                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5075                    != Ordering::Less => {}
5076            _ => {
5077                grouped.insert(row.package_rowid, row);
5078            }
5079        }
5080    }
5081
5082    grouped.into_values().collect()
5083}
5084
5085fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5086    let has_hash = table_has_column(connection, "manifest", "hash")?;
5087    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5088    let sql = format!(
5089        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5090         FROM manifest \
5091         JOIN versions ON manifest.version = versions.rowid \
5092         JOIN channels ON manifest.channel = channels.rowid \
5093         WHERE manifest.id = ?1"
5094    );
5095    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5096        Ok(V1VersionRow {
5097            version: row_string(row, 0)?,
5098            channel: row_string(row, 1)?,
5099            pathpart_id: row_i64(row, 2)?,
5100            manifest_hash: row_opt_hex_string(row, 3)?,
5101        })
5102    })
5103}
5104
5105fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5106    let mut parts = Vec::new();
5107    let mut current = Some(pathpart_id);
5108
5109    while let Some(id) = current {
5110        let (parent, pathpart) = query_optional_value(
5111            connection,
5112            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5113            vec![SqlValue::Integer(id)],
5114            |row| {
5115                Ok((
5116                    row_opt_string(row, 0)?
5117                        .and_then(|value| value.parse::<i64>().ok())
5118                        .filter(|value| *value != 0),
5119                    row_string(row, 1)?,
5120                ))
5121            },
5122        )?
5123        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5124        parts.push(pathpart);
5125        current = parent;
5126    }
5127
5128    parts.reverse();
5129    Ok(parts.join("/"))
5130}
5131
5132fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5133    format!(
5134        "packages/{}/{}/versionData.mszyml",
5135        package_id,
5136        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5137    )
5138}
5139
5140fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5141    let message = format!("{error:#}").to_ascii_lowercase();
5142    message.contains("no such table") && message.contains("packages")
5143}
5144
5145fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5146    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5147        row_string(row, 1)
5148    })? {
5149        if value.eq_ignore_ascii_case(column) {
5150            return Ok(true);
5151        }
5152    }
5153
5154    Ok(false)
5155}
5156
5157fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5158    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5159    let id_column = if v2 { "id" } else { "ids.id" };
5160    let name_column = if v2 { "name" } else { "names.name" };
5161    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5162    let exact_match = query.exact || semantics == SearchSemantics::Single;
5163    let mut params = Vec::new();
5164
5165    if let Some(value) = &query.id {
5166        return (
5167            single_field_condition(id_column, value, exact_match, &mut params),
5168            params,
5169        );
5170    }
5171    if let Some(value) = &query.name {
5172        return (
5173            single_field_condition(name_column, value, exact_match, &mut params),
5174            params,
5175        );
5176    }
5177    if let Some(value) = &query.moniker {
5178        return (
5179            single_field_condition(moniker_column, value, exact_match, &mut params),
5180            params,
5181        );
5182    }
5183    if let Some(value) = &query.tag {
5184        return (
5185            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5186            params,
5187        );
5188    }
5189    if let Some(value) = &query.command {
5190        return (
5191            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5192            params,
5193        );
5194    }
5195    if let Some(value) = &query.query {
5196        if exact_match {
5197            let conditions = [
5198                single_field_condition(id_column, value, true, &mut params),
5199                single_field_condition(name_column, value, true, &mut params),
5200                single_field_condition(moniker_column, value, true, &mut params),
5201            ];
5202            return (format!("({})", conditions.join(" OR ")), params);
5203        }
5204
5205        let mut conditions = vec![
5206            single_field_condition(id_column, value, false, &mut params),
5207            single_field_condition(name_column, value, false, &mut params),
5208            single_field_condition(moniker_column, value, false, &mut params),
5209        ];
5210        if semantics == SearchSemantics::Many {
5211            conditions.push(mapped_field_condition(
5212                v2,
5213                "tag",
5214                value,
5215                rowid_column,
5216                false,
5217                &mut params,
5218            ));
5219            conditions.push(mapped_field_condition(
5220                v2,
5221                "command",
5222                value,
5223                rowid_column,
5224                false,
5225                &mut params,
5226            ));
5227        }
5228        return (format!("({})", conditions.join(" OR ")), params);
5229    }
5230
5231    ("1 = 1".to_owned(), Vec::new())
5232}
5233
5234fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5235    params.push(match_parameter(value, exact));
5236    format!("{column} LIKE ?{}", params.len())
5237}
5238
5239fn mapped_field_condition(
5240    v2: bool,
5241    value_name: &str,
5242    value: &str,
5243    rowid_column: &str,
5244    exact: bool,
5245    params: &mut Vec<String>,
5246) -> String {
5247    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5248        (
5249            format!("{value_name}s2"),
5250            format!("{value_name}s2_map"),
5251            value_name.to_owned(),
5252            "package".to_owned(),
5253        )
5254    } else {
5255        (
5256            format!("{value_name}s"),
5257            format!("{value_name}s_map"),
5258            value_name.to_owned(),
5259            "manifest".to_owned(),
5260        )
5261    };
5262    params.push(match_parameter(value, exact));
5263    let parameter = params.len();
5264    format!(
5265        "EXISTS (SELECT 1 FROM {map_table_name} JOIN {table_name} ON \
5266         {map_table_name}.{value_name} = {table_name}.rowid \
5267         WHERE {map_table_name}.{map_owner_column} = {rowid_column} \
5268         AND {table_name}.{map_value_column} LIKE ?{parameter})"
5269    )
5270}
5271
5272fn match_parameter(value: &str, exact: bool) -> String {
5273    if exact { value.to_owned() } else { format!("%{value}%") }
5274}
5275
5276fn build_rest_search_body(
5277    query: &PackageQuery,
5278    info: &RestInformation,
5279    semantics: SearchSemantics,
5280) -> Result<JsonValue> {
5281    let mut root = serde_json::Map::new();
5282    root.insert(
5283        "MaximumResults".to_owned(),
5284        JsonValue::from(source_fetch_results(query, semantics) as u64),
5285    );
5286    let exact_match = query.exact || semantics == SearchSemantics::Single;
5287
5288    if let Some(value) = &query.query {
5289        if semantics == SearchSemantics::Single {
5290            let mut filters = vec![
5291                rest_filter("PackageIdentifier", value, true),
5292                rest_filter("PackageName", value, true),
5293                rest_filter("Moniker", value, true),
5294            ];
5295            append_required_rest_filters(&mut filters, info);
5296            root.insert("Filters".to_owned(), JsonValue::Array(filters));
5297            return Ok(JsonValue::Object(root));
5298        }
5299
5300        root.insert(
5301            "Query".to_owned(),
5302            serde_json::json!({
5303                "KeyWord": value,
5304                "MatchType": if exact_match { "Exact" } else { "Substring" },
5305            }),
5306        );
5307    }
5308
5309    let mut filters = Vec::new();
5310    if let Some(value) = &query.id {
5311        filters.push(rest_filter("PackageIdentifier", value, exact_match));
5312    }
5313    if let Some(value) = &query.name {
5314        filters.push(rest_filter("PackageName", value, exact_match));
5315    }
5316    if let Some(value) = &query.moniker {
5317        filters.push(rest_filter("Moniker", value, exact_match));
5318    }
5319    if let Some(value) = &query.tag {
5320        filters.push(rest_filter("Tag", value, true));
5321    }
5322    if let Some(value) = &query.command {
5323        filters.push(rest_filter("Command", value, true));
5324    }
5325    append_required_rest_filters(&mut filters, info);
5326
5327    if !filters.is_empty() {
5328        root.insert("Filters".to_owned(), JsonValue::Array(filters));
5329    }
5330
5331    Ok(JsonValue::Object(root))
5332}
5333
5334fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5335    if info
5336        .required_package_match_fields
5337        .iter()
5338        .any(|field| field.eq_ignore_ascii_case("Market"))
5339    {
5340        filters.push(rest_filter("Market", &default_market(), true));
5341    }
5342}
5343
5344fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5345    serde_json::json!({
5346        "PackageMatchField": field,
5347        "RequestMatch": {
5348            "KeyWord": value,
5349            "MatchType": if exact { "Exact" } else { "Substring" },
5350        }
5351    })
5352}
5353
5354fn max_results(query: &PackageQuery) -> usize {
5355    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5356}
5357
5358fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5359    match semantics {
5360        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5361        SearchSemantics::Single => max_results(query),
5362    }
5363}
5364
5365fn infer_preindexed_match_criteria_v2(
5366    connection: &Connection,
5367    row: &V2SearchRow,
5368    query: &PackageQuery,
5369    semantics: SearchSemantics,
5370) -> Result<Option<String>> {
5371    infer_match_criteria(
5372        &row.id,
5373        &row.name,
5374        row.moniker.as_deref(),
5375        query,
5376        semantics,
5377        |value| {
5378            find_mapped_value_v2(
5379                connection,
5380                "tags2",
5381                "tags2_map",
5382                "tag",
5383                row.package_rowid,
5384                value,
5385                query.exact,
5386            )
5387        },
5388        |value| {
5389            find_mapped_value_v2(
5390                connection,
5391                "commands2",
5392                "commands2_map",
5393                "command",
5394                row.package_rowid,
5395                value,
5396                query.exact,
5397            )
5398        },
5399    )
5400}
5401
5402fn infer_preindexed_match_criteria_v1(
5403    connection: &Connection,
5404    row: &V1SearchRow,
5405    query: &PackageQuery,
5406    semantics: SearchSemantics,
5407) -> Result<Option<String>> {
5408    infer_match_criteria(
5409        &row.id,
5410        &row.name,
5411        row.moniker.as_deref(),
5412        query,
5413        semantics,
5414        |value| {
5415            find_mapped_value_v1(
5416                connection,
5417                "tags",
5418                "tags_map",
5419                "tag",
5420                row.manifest_rowid,
5421                value,
5422                query.exact,
5423            )
5424        },
5425        |value| {
5426            find_mapped_value_v1(
5427                connection,
5428                "commands",
5429                "commands_map",
5430                "command",
5431                row.manifest_rowid,
5432                value,
5433                query.exact,
5434            )
5435        },
5436    )
5437}
5438
5439fn infer_match_criteria<FTag, FCommand>(
5440    id: &str,
5441    name: &str,
5442    moniker: Option<&str>,
5443    query: &PackageQuery,
5444    semantics: SearchSemantics,
5445    find_tag: FTag,
5446    find_command: FCommand,
5447) -> Result<Option<String>>
5448where
5449    FTag: FnOnce(&str) -> Result<Option<String>>,
5450    FCommand: FnOnce(&str) -> Result<Option<String>>,
5451{
5452    if let Some(value) = &query.tag {
5453        return Ok(Some(format_match_criteria("Tag", value)));
5454    }
5455    if let Some(value) = &query.command {
5456        return Ok(Some(format_match_criteria("Command", value)));
5457    }
5458    if let Some(value) = &query.moniker {
5459        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
5460    }
5461    if semantics == SearchSemantics::Single {
5462        return Ok(None);
5463    }
5464    if let Some(value) = &query.query {
5465        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
5466            return Ok(None);
5467        }
5468        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
5469            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
5470        }
5471        if let Some(tag) = find_tag(value)? {
5472            return Ok(Some(format_match_criteria("Tag", &tag)));
5473        }
5474        if let Some(command) = find_command(value)? {
5475            return Ok(Some(format_match_criteria("Command", &command)));
5476        }
5477    }
5478    Ok(None)
5479}
5480
5481fn find_mapped_value_v2(
5482    connection: &Connection,
5483    table_name: &str,
5484    map_table_name: &str,
5485    value_name: &str,
5486    package_rowid: i64,
5487    query: &str,
5488    exact: bool,
5489) -> Result<Option<String>> {
5490    let sql = format!(
5491        "SELECT {table_name}.{value_name} FROM {map_table_name} \
5492         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5493         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
5494    );
5495    let values = query_rows(
5496        connection,
5497        &sql,
5498        vec![
5499            SqlValue::Integer(package_rowid),
5500            SqlValue::Text(match_parameter(query, exact)),
5501        ],
5502        |row| row_string(row, 0),
5503    )?;
5504    Ok(select_best_text_match(values, query, exact))
5505}
5506
5507fn find_mapped_value_v1(
5508    connection: &Connection,
5509    table_name: &str,
5510    map_table_name: &str,
5511    value_name: &str,
5512    manifest_rowid: i64,
5513    query: &str,
5514    exact: bool,
5515) -> Result<Option<String>> {
5516    let sql = format!(
5517        "SELECT {table_name}.{value_name} FROM {map_table_name} \
5518         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5519         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
5520    );
5521    let values = query_rows(
5522        connection,
5523        &sql,
5524        vec![
5525            SqlValue::Integer(manifest_rowid),
5526            SqlValue::Text(match_parameter(query, exact)),
5527        ],
5528        |row| row_string(row, 0),
5529    )?;
5530    Ok(select_best_text_match(values, query, exact))
5531}
5532
5533fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
5534    if let Some(value) = &query.tag {
5535        return Some(format_match_criteria("Tag", value));
5536    }
5537    if let Some(value) = &query.command {
5538        return Some(format_match_criteria("Command", value));
5539    }
5540    if let Some(value) = &query.moniker {
5541        return Some(format_match_criteria("Moniker", value));
5542    }
5543    if semantics == SearchSemantics::Single {
5544        return None;
5545    }
5546    let value = query.query.as_deref()?;
5547    if json_string(item, "PackageIdentifier")
5548        .as_deref()
5549        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5550        || json_string(item, "PackageName")
5551            .as_deref()
5552            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5553    {
5554        return None;
5555    }
5556    json_string(item, "Moniker")
5557        .filter(|candidate| matches_text(candidate, value, query.exact))
5558        .map(|candidate| format_match_criteria("Moniker", &candidate))
5559}
5560
5561fn format_match_criteria(field: &str, value: &str) -> String {
5562    format!("{field}: {value}")
5563}
5564
5565fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
5566    if exact {
5567        candidate.eq_ignore_ascii_case(query)
5568    } else {
5569        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
5570    }
5571}
5572
5573fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
5574    let mut score = 0;
5575
5576    if let Some(value) = &query.query {
5577        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
5578        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
5579        if let Some(moniker) = candidate.moniker.as_deref() {
5580            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
5581        }
5582    }
5583
5584    if let Some(value) = &query.id {
5585        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
5586    }
5587    if let Some(value) = &query.name {
5588        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
5589    }
5590    if let Some(value) = &query.moniker
5591        && let Some(moniker) = candidate.moniker.as_deref()
5592    {
5593        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
5594    }
5595    if let Some(value) = &query.tag
5596        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5597    {
5598        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
5599    }
5600    if let Some(value) = &query.command
5601        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5602    {
5603        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
5604    }
5605
5606    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
5607        score += 5;
5608    }
5609    if search_match_has_unknown_version(candidate) {
5610        score = score.saturating_sub(10);
5611    }
5612
5613    score
5614}
5615
5616fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
5617    candidate
5618        .version
5619        .as_deref()
5620        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
5621}
5622
5623fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
5624    if manifest_path.is_file() {
5625        return Ok(manifest_path.to_path_buf());
5626    }
5627
5628    if !manifest_path.is_dir() {
5629        bail!("Manifest path not found: {}", manifest_path.display());
5630    }
5631
5632    let candidate = fs::read_dir(manifest_path)?
5633        .filter_map(|entry| entry.ok())
5634        .map(|entry| entry.path())
5635        .filter(|path| {
5636            path.extension()
5637                .and_then(|value| value.to_str())
5638                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
5639        })
5640        .min();
5641
5642    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
5643}
5644
5645fn score_text_match(
5646    candidate: &str,
5647    query: &str,
5648    exact: bool,
5649    exact_score: usize,
5650    prefix_score: usize,
5651    substring_score: usize,
5652) -> usize {
5653    if candidate.eq_ignore_ascii_case(query) {
5654        return exact_score;
5655    }
5656    if exact {
5657        return 0;
5658    }
5659
5660    let candidate_lower = candidate.to_ascii_lowercase();
5661    let query_lower = query.to_ascii_lowercase();
5662    if candidate_lower.starts_with(&query_lower) {
5663        prefix_score
5664    } else if candidate_lower.contains(&query_lower) {
5665        substring_score
5666    } else {
5667        0
5668    }
5669}
5670
5671fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
5672where
5673    I: IntoIterator<Item = String>,
5674{
5675    values
5676        .into_iter()
5677        .filter_map(|candidate| {
5678            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
5679            (score > 0).then_some((score, candidate))
5680        })
5681        .max_by(|(left_score, left_value), (right_score, right_value)| {
5682            left_score
5683                .cmp(right_score)
5684                .then_with(|| right_value.len().cmp(&left_value.len()))
5685                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
5686        })
5687        .map(|(_, candidate)| candidate)
5688}
5689
5690fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
5691    let (field, value) = criteria.split_once(": ")?;
5692    Some((field, value))
5693}
5694
5695fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
5696    let versions = item
5697        .get("Versions")
5698        .and_then(JsonValue::as_array)
5699        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
5700    let mut result = Vec::new();
5701    for version in versions {
5702        let value = json_string(version, "PackageVersion")
5703            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
5704        let channel = json_string(version, "Channel").unwrap_or_default();
5705        result.push(VersionKey {
5706            version: value,
5707            channel,
5708        });
5709    }
5710    Ok(result)
5711}
5712
5713fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
5714    let mut merged = YamlMapping::new();
5715    let mut documents = Vec::new();
5716    for document in serde_yaml::Deserializer::from_slice(bytes) {
5717        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
5718        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
5719        if let Some(mapping) = value.as_mapping() {
5720            merge_yaml_mapping(&mut merged, mapping);
5721        }
5722    }
5723
5724    let id = yaml_string_from_root(&merged, "PackageIdentifier")
5725        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
5726    let version =
5727        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
5728    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
5729
5730    let installers = parse_yaml_installers(&merged);
5731    let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
5732    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
5733
5734    Ok((
5735        Manifest {
5736            id,
5737            name,
5738            version,
5739            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
5740            publisher: yaml_localized_string(&merged, "Publisher"),
5741            description: yaml_localized_string(&merged, "Description")
5742                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
5743            moniker: yaml_string_from_root(&merged, "Moniker"),
5744            package_url: yaml_localized_string(&merged, "PackageUrl"),
5745            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
5746            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
5747            license: yaml_localized_string(&merged, "License"),
5748            license_url: yaml_localized_string(&merged, "LicenseUrl"),
5749            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
5750            author: yaml_localized_string(&merged, "Author"),
5751            copyright: yaml_localized_string(&merged, "Copyright"),
5752            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
5753            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
5754            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
5755            tags: yaml_string_list(&merged, "Tags"),
5756            agreements: yaml_agreement_list(&merged),
5757            package_dependencies: yaml_package_dependencies(&merged),
5758            documentation: yaml_documentation_list(&merged),
5759            installers,
5760            require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
5761        },
5762        collapse_structured_document(&JsonValue::Array(documents)),
5763    ))
5764}
5765
5766fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
5767    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
5768}
5769
5770fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
5771    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
5772    let data = root
5773        .get("Data")
5774        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
5775    let versions = data
5776        .get("Versions")
5777        .and_then(JsonValue::as_array)
5778        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
5779    let selected = versions
5780        .iter()
5781        .find(|item| {
5782            json_string(item, "PackageVersion").as_deref() == Some(version)
5783                && json_string(item, "Channel").unwrap_or_default() == channel
5784        })
5785        .or_else(|| versions.first())
5786        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
5787
5788    let default_locale = selected
5789        .get("DefaultLocale")
5790        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
5791    let name = json_string(default_locale, "PackageName")
5792        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
5793    let installer_switch_defaults = json_installer_switches(selected);
5794    let top_platforms = json_string_list(selected, "Platform");
5795    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
5796    let top_installer_type = json_string(selected, "InstallerType");
5797    let top_nested_installer_type = json_string(selected, "NestedInstallerType");
5798    let top_nested_installer_files = json_nested_installer_files(selected);
5799
5800    let installers = selected
5801        .get("Installers")
5802        .and_then(JsonValue::as_array)
5803        .map(|items| {
5804            items
5805                .iter()
5806                .map(|item| Installer {
5807                    platforms: {
5808                        let platforms = json_string_list(item, "Platform");
5809                        if platforms.is_empty() {
5810                            top_platforms.clone()
5811                        } else {
5812                            platforms
5813                        }
5814                    },
5815                    minimum_os_version: json_string(item, "MinimumOSVersion")
5816                        .or_else(|| top_minimum_os_version.clone()),
5817                    architecture: json_string(item, "Architecture"),
5818                    installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
5819                    nested_installer_type: json_string(item, "NestedInstallerType")
5820                        .or_else(|| top_nested_installer_type.clone()),
5821                    nested_installer_files: {
5822                        let files = json_nested_installer_files(item);
5823                        if files.is_empty() {
5824                            top_nested_installer_files.clone()
5825                        } else {
5826                            files
5827                        }
5828                    },
5829                    url: json_string(item, "InstallerUrl"),
5830                    sha256: json_string(item, "InstallerSha256"),
5831                    product_code: json_string(item, "ProductCode"),
5832                    locale: json_string(item, "InstallerLocale"),
5833                    scope: json_string(item, "Scope"),
5834                    release_date: json_string(item, "ReleaseDate"),
5835                    package_family_name: json_string(item, "PackageFamilyName"),
5836                    upgrade_code: json_string(item, "UpgradeCode"),
5837                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
5838                    commands: json_string_list(item, "Commands"),
5839                    package_dependencies: json_package_dependencies(item),
5840                    require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
5841                })
5842                .collect::<Vec<_>>()
5843        })
5844        .unwrap_or_default();
5845    let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
5846    let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
5847
5848    let manifest = Manifest {
5849        id: package_id.to_owned(),
5850        name,
5851        version: version.to_owned(),
5852        channel: channel.to_owned(),
5853        publisher: json_string(default_locale, "Publisher"),
5854        description: json_string(default_locale, "Description")
5855            .or_else(|| json_string(default_locale, "ShortDescription")),
5856        moniker: json_string(default_locale, "Moniker"),
5857        package_url: json_string(default_locale, "PackageUrl"),
5858        publisher_url: json_string(default_locale, "PublisherUrl"),
5859        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
5860        license: json_string(default_locale, "License"),
5861        license_url: json_string(default_locale, "LicenseUrl"),
5862        privacy_url: json_string(default_locale, "PrivacyUrl"),
5863        author: json_string(default_locale, "Author"),
5864        copyright: json_string(default_locale, "Copyright"),
5865        copyright_url: json_string(default_locale, "CopyrightUrl"),
5866        release_notes: json_string(default_locale, "ReleaseNotes"),
5867        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
5868        tags: json_string_list(default_locale, "Tags"),
5869        agreements: json_agreement_list(default_locale),
5870        package_dependencies: json_package_dependencies(selected),
5871        documentation: json_documentation_list(default_locale),
5872        installers,
5873        require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
5874    };
5875
5876    Ok((
5877        manifest,
5878        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
5879    ))
5880}
5881
5882fn collapse_structured_document(document: &JsonValue) -> JsonValue {
5883    match document {
5884        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
5885            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
5886        }
5887        JsonValue::Array(documents) => merge_manifest_documents(documents),
5888        JsonValue::Object(_) if is_merged_manifest_document(document) => {
5889            merge_manifest_documents(&split_merged_manifest_document(document))
5890        }
5891        _ => document.clone(),
5892    }
5893}
5894
5895fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
5896    documents.iter().map(collapse_structured_document).collect()
5897}
5898
5899fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
5900    let version = documents.iter().find(|document| {
5901        document
5902            .get("ManifestType")
5903            .and_then(JsonValue::as_str)
5904            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
5905    });
5906    let default_locale = documents.iter().find(|document| {
5907        document
5908            .get("ManifestType")
5909            .and_then(JsonValue::as_str)
5910            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
5911    });
5912    let installer = documents.iter().find(|document| {
5913        document
5914            .get("ManifestType")
5915            .and_then(JsonValue::as_str)
5916            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
5917    });
5918
5919    let mut singleton = JsonMap::new();
5920    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
5921    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
5922    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
5923
5924    if !singleton.contains_key("PackageLocale")
5925        && let Some(package_locale) = default_locale
5926            .and_then(|document| json_string(document, "PackageLocale"))
5927            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
5928    {
5929        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
5930    }
5931
5932    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
5933    singleton.insert(
5934        "ManifestVersion".to_owned(),
5935        JsonValue::String(
5936            installer
5937                .and_then(|document| json_string(document, "ManifestVersion"))
5938                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
5939                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
5940                .unwrap_or_else(|| "1.10.0".to_owned()),
5941        ),
5942    );
5943    singleton.remove("DefaultLocale");
5944
5945    JsonValue::Object(singleton)
5946}
5947
5948fn is_merged_manifest_document(document: &JsonValue) -> bool {
5949    document
5950        .get("ManifestType")
5951        .and_then(JsonValue::as_str)
5952        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
5953}
5954
5955fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
5956    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
5957    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
5958    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
5959    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
5960
5961    let version_document = serde_json::json!({
5962        "PackageIdentifier": package_identifier,
5963        "PackageVersion": package_version,
5964        "DefaultLocale": package_locale,
5965        "ManifestType": "version",
5966        "ManifestVersion": manifest_version,
5967    });
5968
5969    let mut default_locale_document = project_manifest_document(
5970        merged,
5971        &[
5972            "PackageIdentifier",
5973            "PackageVersion",
5974            "PackageLocale",
5975            "Publisher",
5976            "PublisherUrl",
5977            "PublisherSupportUrl",
5978            "PrivacyUrl",
5979            "Author",
5980            "PackageName",
5981            "PackageUrl",
5982            "License",
5983            "LicenseUrl",
5984            "Copyright",
5985            "CopyrightUrl",
5986            "ShortDescription",
5987            "Description",
5988            "Moniker",
5989            "Tags",
5990            "Agreements",
5991            "ReleaseNotes",
5992            "ReleaseNotesUrl",
5993            "PurchaseUrl",
5994            "InstallationNotes",
5995            "Documentations",
5996            "Icons",
5997        ],
5998    );
5999    default_locale_document.insert(
6000        "PackageIdentifier".to_owned(),
6001        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6002    );
6003    default_locale_document.insert(
6004        "PackageVersion".to_owned(),
6005        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6006    );
6007    default_locale_document.insert(
6008        "PackageLocale".to_owned(),
6009        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
6010    );
6011    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6012    default_locale_document.insert(
6013        "ManifestVersion".to_owned(),
6014        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6015    );
6016
6017    let mut installer_document = project_manifest_document(
6018        merged,
6019        &[
6020            "PackageIdentifier",
6021            "PackageVersion",
6022            "Channel",
6023            "InstallerLocale",
6024            "Platform",
6025            "MinimumOSVersion",
6026            "InstallerType",
6027            "NestedInstallerType",
6028            "NestedInstallerFiles",
6029            "Scope",
6030            "InstallModes",
6031            "InstallerSwitches",
6032            "InstallerSuccessCodes",
6033            "ExpectedReturnCodes",
6034            "UpgradeBehavior",
6035            "Commands",
6036            "Protocols",
6037            "FileExtensions",
6038            "Dependencies",
6039            "PackageFamilyName",
6040            "ProductCode",
6041            "Capabilities",
6042            "RestrictedCapabilities",
6043            "Markets",
6044            "InstallerAbortsTerminal",
6045            "ReleaseDate",
6046            "InstallLocationRequired",
6047            "RequireExplicitUpgrade",
6048            "DisplayInstallWarnings",
6049            "UnsupportedOSArchitectures",
6050            "UnsupportedArguments",
6051            "AppsAndFeaturesEntries",
6052            "ElevationRequirement",
6053            "InstallationMetadata",
6054            "DownloadCommandProhibited",
6055            "RepairBehavior",
6056            "ArchiveBinariesDependOnPath",
6057            "Authentication",
6058            "Installers",
6059        ],
6060    );
6061    installer_document.insert(
6062        "PackageIdentifier".to_owned(),
6063        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6064    );
6065    installer_document.insert(
6066        "PackageVersion".to_owned(),
6067        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6068    );
6069    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6070    installer_document.insert(
6071        "ManifestVersion".to_owned(),
6072        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6073    );
6074
6075    vec![
6076        version_document,
6077        JsonValue::Object(default_locale_document),
6078        JsonValue::Object(installer_document),
6079    ]
6080}
6081
6082fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6083    let mut result = JsonMap::new();
6084    if let Some(source) = source.as_object() {
6085        for key in keys {
6086            if let Some(value) = source.get(*key) {
6087                result.insert((*key).to_owned(), value.clone());
6088            }
6089        }
6090    }
6091
6092    result
6093}
6094
6095fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6096    if let Some(source) = source.and_then(JsonValue::as_object) {
6097        for key in keys {
6098            if let Some(value) = source.get(*key) {
6099                target.insert((*key).to_owned(), value.clone());
6100            }
6101        }
6102    }
6103}
6104
6105fn copy_all_manifest_keys_except(
6106    target: &mut JsonMap<String, JsonValue>,
6107    source: Option<&JsonValue>,
6108    excluded_keys: &[&str],
6109) {
6110    if let Some(source) = source.and_then(JsonValue::as_object) {
6111        for (key, value) in source {
6112            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6113                target.insert(key.clone(), value.clone());
6114            }
6115        }
6116    }
6117}
6118
6119fn build_rest_manifest_documents(
6120    data: &JsonValue,
6121    installer_source: &JsonValue,
6122    default_locale: &JsonValue,
6123    package_id: &str,
6124    version: &str,
6125    channel: &str,
6126) -> JsonValue {
6127    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6128    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6129    let manifest_version = json_string(installer_source, "ManifestVersion")
6130        .or_else(|| json_string(default_locale, "ManifestVersion"))
6131        .or_else(|| json_string(data, "ManifestVersion"))
6132        .unwrap_or_else(|| "1.10.0".to_owned());
6133
6134    let mut version_document = JsonMap::new();
6135    version_document.insert(
6136        "PackageIdentifier".to_owned(),
6137        JsonValue::String(package_identifier.clone()),
6138    );
6139    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6140    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6141    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6142    version_document.insert(
6143        "ManifestVersion".to_owned(),
6144        JsonValue::String(manifest_version.clone()),
6145    );
6146
6147    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6148    default_locale_document.insert(
6149        "PackageIdentifier".to_owned(),
6150        JsonValue::String(package_identifier.clone()),
6151    );
6152    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6153    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6154    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6155    default_locale_document.insert(
6156        "ManifestVersion".to_owned(),
6157        JsonValue::String(manifest_version.clone()),
6158    );
6159
6160    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6161    installer_document.remove("DefaultLocale");
6162    installer_document.remove("Locales");
6163    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6164    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6165    if !channel.is_empty() {
6166        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6167    }
6168    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6169    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6170
6171    let documents = vec![
6172        JsonValue::Object(version_document),
6173        JsonValue::Object(default_locale_document),
6174        JsonValue::Object(installer_document),
6175    ];
6176
6177    merge_manifest_documents(&documents)
6178}
6179
6180fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6181    let base = installer_defaults(root);
6182    let base_switches = yaml_installer_switches(root);
6183    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6184        let installers = items
6185            .iter()
6186            .filter_map(YamlValue::as_mapping)
6187            .map(|item| {
6188                let mut merged = base.clone();
6189                merge_yaml_mapping(&mut merged, item);
6190                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6191                installer_from_yaml(&merged, switches)
6192            })
6193            .collect::<Vec<_>>();
6194        if !installers.is_empty() {
6195            return installers;
6196        }
6197    }
6198
6199    if base.is_empty() {
6200        Vec::new()
6201    } else {
6202        vec![installer_from_yaml(&base, base_switches)]
6203    }
6204}
6205
6206fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6207    let keys = [
6208        "Architecture",
6209        "InstallerType",
6210        "NestedInstallerType",
6211        "NestedInstallerFiles",
6212        "InstallerUrl",
6213        "InstallerSha256",
6214        "ProductCode",
6215        "InstallerLocale",
6216        "Platform",
6217        "MinimumOSVersion",
6218        "Scope",
6219        "ReleaseDate",
6220        "PackageFamilyName",
6221        "UpgradeCode",
6222        "Commands",
6223    ];
6224    let mut defaults = YamlMapping::new();
6225    for key in keys {
6226        if let Some(value) = root.get(YamlValue::from(key)) {
6227            defaults.insert(YamlValue::from(key), value.clone());
6228        }
6229    }
6230    defaults
6231}
6232
6233fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6234    Installer {
6235        architecture: yaml_string(root, "Architecture"),
6236        installer_type: yaml_string(root, "InstallerType"),
6237        nested_installer_type: yaml_string(root, "NestedInstallerType"),
6238        nested_installer_files: yaml_nested_installer_files(root),
6239        url: yaml_string(root, "InstallerUrl"),
6240        sha256: yaml_string(root, "InstallerSha256"),
6241        product_code: yaml_string(root, "ProductCode"),
6242        locale: yaml_string(root, "InstallerLocale"),
6243        scope: yaml_string(root, "Scope"),
6244        release_date: yaml_string(root, "ReleaseDate"),
6245        package_family_name: yaml_string(root, "PackageFamilyName"),
6246        upgrade_code: yaml_string(root, "UpgradeCode"),
6247        platforms: yaml_string_list(root, "Platform"),
6248        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6249        switches,
6250        commands: yaml_string_list(root, "Commands"),
6251        package_dependencies: yaml_package_dependencies(root),
6252        require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6253    }
6254}
6255
6256/// Reads a YAML scalar boolean. Accepts `true`/`false` (any casing) plus
6257/// the legacy `True`/`False` variants the winget catalog mixes in.
6258fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6259    let Some(value) = root.get(YamlValue::from(key)) else {
6260        return false;
6261    };
6262    match value {
6263        YamlValue::Bool(b) => *b,
6264        YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6265        _ => false,
6266    }
6267}
6268
6269fn json_bool(value: &JsonValue, key: &str) -> bool {
6270    match value.get(key) {
6271        Some(JsonValue::Bool(b)) => *b,
6272        Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6273        _ => false,
6274    }
6275}
6276
6277fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
6278    let mapping = root
6279        .get(YamlValue::from("InstallerSwitches"))
6280        .or_else(|| root.get(YamlValue::from("Switches")))
6281        .and_then(YamlValue::as_mapping);
6282    yaml_installer_switches_from_mapping(mapping)
6283}
6284
6285fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
6286    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
6287
6288    InstallerSwitches {
6289        silent: string("Silent"),
6290        silent_with_progress: string("SilentWithProgress"),
6291        interactive: string("Interactive"),
6292        custom: string("Custom"),
6293        log: string("Log"),
6294        install_location: string("InstallLocation"),
6295    }
6296}
6297
6298fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
6299    yaml_string(root, key).or_else(|| {
6300        root.get(YamlValue::from("DefaultLocale"))
6301            .and_then(YamlValue::as_mapping)
6302            .and_then(|mapping| yaml_string(mapping, key))
6303    })
6304}
6305
6306fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
6307    yaml_string(root, key)
6308}
6309
6310fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
6311    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
6312}
6313
6314fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
6315    match value {
6316        YamlValue::Null => None,
6317        YamlValue::Bool(value) => Some(value.to_string()),
6318        YamlValue::Number(value) => Some(value.to_string()),
6319        YamlValue::String(value) => Some(value.clone()),
6320        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
6321        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
6322    }
6323}
6324
6325fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
6326    root.get(YamlValue::from(key))
6327        .and_then(YamlValue::as_sequence)
6328        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
6329        .unwrap_or_default()
6330}
6331
6332fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
6333    root.get(YamlValue::from("Documentations"))
6334        .and_then(YamlValue::as_sequence)
6335        .map(|items| {
6336            items
6337                .iter()
6338                .filter_map(YamlValue::as_mapping)
6339                .filter_map(|item| {
6340                    let url = yaml_string(item, "DocumentUrl")?;
6341                    Some(Documentation {
6342                        label: yaml_string(item, "DocumentLabel"),
6343                        url,
6344                    })
6345                })
6346                .collect()
6347        })
6348        .unwrap_or_default()
6349}
6350
6351fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
6352    root.get(YamlValue::from("Agreements"))
6353        .and_then(YamlValue::as_sequence)
6354        .map(|items| {
6355            items
6356                .iter()
6357                .filter_map(YamlValue::as_mapping)
6358                .map(|item| PackageAgreement {
6359                    label: yaml_string(item, "AgreementLabel"),
6360                    text: yaml_string(item, "Agreement"),
6361                    url: yaml_string(item, "AgreementUrl"),
6362                })
6363                .collect()
6364        })
6365        .unwrap_or_default()
6366}
6367
6368fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
6369    root.get(YamlValue::from("Dependencies"))
6370        .and_then(YamlValue::as_mapping)
6371        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
6372        .and_then(YamlValue::as_sequence)
6373        .map(|items| {
6374            items
6375                .iter()
6376                .filter_map(YamlValue::as_mapping)
6377                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
6378                .collect()
6379        })
6380        .unwrap_or_default()
6381}
6382
6383fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
6384    root.get(YamlValue::from("NestedInstallerFiles"))
6385        .and_then(YamlValue::as_sequence)
6386        .map(|items| {
6387            items
6388                .iter()
6389                .filter_map(YamlValue::as_mapping)
6390                .filter_map(|item| {
6391                    let relative = yaml_string(item, "RelativeFilePath")?;
6392                    Some(NestedInstallerFile {
6393                        relative_file_path: relative,
6394                        portable_command_alias: yaml_string(item, "PortableCommandAlias"),
6395                    })
6396                })
6397                .collect()
6398        })
6399        .unwrap_or_default()
6400}
6401
6402fn json_string(value: &JsonValue, key: &str) -> Option<String> {
6403    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
6404}
6405
6406fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
6407    value
6408        .get(key)
6409        .and_then(JsonValue::as_array)
6410        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
6411        .unwrap_or_default()
6412}
6413
6414fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
6415    value
6416        .get("Documentations")
6417        .and_then(JsonValue::as_array)
6418        .map(|items| {
6419            items
6420                .iter()
6421                .filter_map(|item| {
6422                    let url = json_string(item, "DocumentUrl")?;
6423                    Some(Documentation {
6424                        label: json_string(item, "DocumentLabel"),
6425                        url,
6426                    })
6427                })
6428                .collect()
6429        })
6430        .unwrap_or_default()
6431}
6432
6433fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
6434    value
6435        .get("Agreements")
6436        .and_then(JsonValue::as_array)
6437        .map(|items| {
6438            items
6439                .iter()
6440                .map(|item| PackageAgreement {
6441                    label: json_string(item, "AgreementLabel"),
6442                    text: json_string(item, "Agreement"),
6443                    url: json_string(item, "AgreementUrl"),
6444                })
6445                .collect()
6446        })
6447        .unwrap_or_default()
6448}
6449
6450fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
6451    value
6452        .get("Dependencies")
6453        .and_then(JsonValue::as_object)
6454        .and_then(|deps| deps.get("PackageDependencies"))
6455        .and_then(JsonValue::as_array)
6456        .map(|items| {
6457            items
6458                .iter()
6459                .filter_map(|item| json_string(item, "PackageIdentifier"))
6460                .collect()
6461        })
6462        .unwrap_or_default()
6463}
6464
6465fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
6466    value
6467        .get("NestedInstallerFiles")
6468        .and_then(JsonValue::as_array)
6469        .map(|items| {
6470            items
6471                .iter()
6472                .filter_map(|item| {
6473                    let relative = json_string(item, "RelativeFilePath")?;
6474                    Some(NestedInstallerFile {
6475                        relative_file_path: relative,
6476                        portable_command_alias: json_string(item, "PortableCommandAlias"),
6477                    })
6478                })
6479                .collect()
6480        })
6481        .unwrap_or_default()
6482}
6483
6484fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
6485    let switches = value
6486        .get("InstallerSwitches")
6487        .or_else(|| value.get("Switches"))
6488        .and_then(JsonValue::as_object);
6489
6490    let string = |key: &str| {
6491        switches
6492            .and_then(|switches| switches.get(key))
6493            .and_then(JsonValue::as_str)
6494            .map(str::to_owned)
6495    };
6496
6497    InstallerSwitches {
6498        silent: string("Silent"),
6499        silent_with_progress: string("SilentWithProgress"),
6500        interactive: string("Interactive"),
6501        custom: string("Custom"),
6502        log: string("Log"),
6503        install_location: string("InstallLocation"),
6504    }
6505}
6506
6507fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
6508    for (key, value) in source {
6509        target.insert(key.clone(), value.clone());
6510    }
6511}
6512
6513fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
6514    let reader = Cursor::new(bytes.to_vec());
6515    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
6516    let mut file = archive
6517        .by_name(member_name)
6518        .with_context(|| format!("zip payload missing {member_name}"))?;
6519    let mut output = Vec::new();
6520    file.read_to_end(&mut output)
6521        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
6522    Ok(output)
6523}
6524
6525fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
6526    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
6527        return Ok(output);
6528    }
6529
6530    if !bytes.starts_with(b"CK")
6531        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
6532    {
6533        return decompress_mszyml_payload(&bytes[offset + 2..]);
6534    }
6535
6536    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
6537}
6538
6539fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
6540    let mut decoder = flate2::read::DeflateDecoder::new(payload);
6541    let mut output = String::new();
6542    decoder.read_to_string(&mut output)?;
6543    Ok(output)
6544}
6545
6546fn cache_control_max_age(response: &Response) -> u64 {
6547    response
6548        .headers()
6549        .get(reqwest::header::CACHE_CONTROL)
6550        .and_then(|value| value.to_str().ok())
6551        .and_then(|header| {
6552            header.split(',').find_map(|part| {
6553                let trimmed = part.trim();
6554                trimmed
6555                    .strip_prefix("max-age=")
6556                    .and_then(|value| value.parse::<u64>().ok())
6557            })
6558        })
6559        .unwrap_or(60)
6560}
6561
6562fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
6563    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
6564        let (major, minor) = major_minor(candidate);
6565        server_versions.iter().any(|version| {
6566            let (server_major, server_minor) = major_minor(version);
6567            server_major == major && server_minor == minor
6568        })
6569    })
6570}
6571
6572fn major_minor(version: &str) -> (String, String) {
6573    let mut parts = version.split('.');
6574    (
6575        parts.next().unwrap_or_default().to_owned(),
6576        parts.next().unwrap_or_default().to_owned(),
6577    )
6578}
6579
6580fn default_market() -> String {
6581    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
6582}
6583
6584fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
6585    let requested_locale = query.locale.as_deref();
6586    let requested_architecture = query.installer_architecture.as_deref();
6587    let requested_type = query.installer_type.as_deref();
6588    let requested_scope = query.install_scope.as_deref();
6589    let requested_platform = query.platform.as_deref();
6590    let requested_os_version = query.os_version.as_deref();
6591    let system_architecture = current_architecture();
6592    let system_platform = current_platform();
6593    let current_os_version = current_os_version();
6594
6595    installers
6596        .iter()
6597        .enumerate()
6598        .filter(|(_, installer)| {
6599            installer_matches_requested(
6600                installer,
6601                requested_type,
6602                requested_scope,
6603                requested_platform,
6604                requested_os_version,
6605                system_platform,
6606                current_os_version.as_deref(),
6607            )
6608        })
6609        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
6610        .max_by_key(|(index, installer)| {
6611            (
6612                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
6613                std::cmp::Reverse(*index),
6614            )
6615        })
6616        .map(|(_, installer)| installer)
6617        .cloned()
6618}
6619
6620fn installer_matches_requested(
6621    installer: &Installer,
6622    requested_type: Option<&str>,
6623    requested_scope: Option<&str>,
6624    requested_platform: Option<&str>,
6625    requested_os_version: Option<&str>,
6626    system_platform: Option<&str>,
6627    current_os_version: Option<&str>,
6628) -> bool {
6629    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
6630        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
6631        && installer_matches_platform(installer, requested_platform, system_platform)
6632        && installer_matches_os_version(installer, requested_os_version, current_os_version)
6633}
6634
6635fn installer_matches_platform(
6636    installer: &Installer,
6637    requested_platform: Option<&str>,
6638    system_platform: Option<&str>,
6639) -> bool {
6640    if installer.platforms.is_empty() {
6641        return true;
6642    }
6643
6644    let Some(effective_platform) = requested_platform.or(system_platform) else {
6645        return true;
6646    };
6647
6648    installer
6649        .platforms
6650        .iter()
6651        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
6652}
6653
6654fn installer_matches_os_version(
6655    installer: &Installer,
6656    requested_os_version: Option<&str>,
6657    current_os_version: Option<&str>,
6658) -> bool {
6659    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
6660        return true;
6661    };
6662
6663    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
6664        return true;
6665    };
6666
6667    match (
6668        parse_dotted_version(minimum_os_version),
6669        parse_dotted_version(actual_os_version),
6670    ) {
6671        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
6672        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
6673    }
6674}
6675
6676fn installer_matches_architecture(
6677    installer: &Installer,
6678    requested_architecture: Option<&str>,
6679    system_architecture: &str,
6680) -> bool {
6681    let Some(architecture) = installer.architecture.as_deref() else {
6682        return true;
6683    };
6684
6685    let architecture = architecture.to_ascii_lowercase();
6686    if let Some(requested) = requested_architecture {
6687        return architecture.eq_ignore_ascii_case(requested);
6688    }
6689
6690    preferred_architectures(system_architecture)
6691        .iter()
6692        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
6693}
6694
6695fn installer_rank(
6696    installer: &Installer,
6697    requested_locale: Option<&str>,
6698    requested_architecture: Option<&str>,
6699    system_architecture: &str,
6700) -> (i32, i32, i32) {
6701    let architecture_rank = architecture_rank(
6702        installer.architecture.as_deref(),
6703        requested_architecture.unwrap_or(system_architecture),
6704        requested_architecture.is_some(),
6705        system_architecture,
6706    );
6707    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
6708    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
6709    (architecture_rank, locale_rank, command_rank)
6710}
6711
6712fn architecture_rank(
6713    installer_architecture: Option<&str>,
6714    preferred_architecture: &str,
6715    strict: bool,
6716    system_architecture: &str,
6717) -> i32 {
6718    let Some(value) = installer_architecture else {
6719        return 0;
6720    };
6721    if value.eq_ignore_ascii_case(preferred_architecture) {
6722        return 5;
6723    }
6724    if value.eq_ignore_ascii_case("neutral") {
6725        return 4;
6726    }
6727    if strict {
6728        return -1;
6729    }
6730
6731    preferred_architectures(system_architecture)
6732        .iter()
6733        .rev()
6734        .position(|candidate| value.eq_ignore_ascii_case(candidate))
6735        .and_then(|index| i32::try_from(index).ok())
6736        .and_then(|index| index.checked_add(1))
6737        .unwrap_or(-1)
6738}
6739
6740/// True when at least one installer in `manifest.installers` targets an
6741/// architecture the host can run. winget hides upgrades whose latest
6742/// version publishes no compatible installer.
6743fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
6744    if manifest.installers.is_empty() {
6745        return true;
6746    }
6747    let allowed = preferred_architectures(current_architecture());
6748    manifest.installers.iter().any(|installer| {
6749        let arch = installer.architecture.as_deref().unwrap_or("neutral");
6750        allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
6751    })
6752}
6753
6754fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
6755    match system_architecture {
6756        "arm64" => &["arm64", "neutral", "x64", "x86"],
6757        "x64" => &["x64", "neutral", "x86"],
6758        "x86" => &["x86", "neutral"],
6759        _ => &["neutral"],
6760    }
6761}
6762
6763fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
6764    match (installer_locale, requested_locale) {
6765        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
6766        (Some(installer), Some(requested))
6767            if installer
6768                .split('-')
6769                .next()
6770                .zip(requested.split('-').next())
6771                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
6772        {
6773            2
6774        }
6775        (None, Some(_)) => 1,
6776        (Some(_), None) => 1,
6777        (None, None) => 0,
6778        _ => -1,
6779    }
6780}
6781
6782fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
6783    match requested {
6784        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
6785        None => true,
6786    }
6787}
6788
6789#[cfg(windows)]
6790fn current_architecture() -> &'static str {
6791    use std::sync::OnceLock;
6792    static CELL: OnceLock<&'static str> = OnceLock::new();
6793    CELL.get_or_init(|| {
6794        // IsWow64Process2's `native_machine` is the OS architecture even
6795        // when the process is running under emulation (x64 binary on
6796        // arm64 Windows, x86 on x64). Compile-time `consts::ARCH` would
6797        // mis-report the host arch in that case.
6798        use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
6799        let mut process_machine: u16 = 0;
6800        let mut native_machine: u16 = 0;
6801        // SAFETY: GetCurrentProcess returns a pseudo-handle that never fails;
6802        // both out-pointers reference local stack u16s that outlive the call.
6803        let handle = unsafe { GetCurrentProcess() };
6804        // SAFETY: handle is valid per above; pointers are valid.
6805        let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
6806        if ok != 0 {
6807            match native_machine {
6808                0xAA64 => return "arm64",
6809                0x8664 => return "x64",
6810                0x014C => return "x86",
6811                _ => {}
6812            }
6813        }
6814        compiled_architecture()
6815    })
6816}
6817
6818#[cfg(not(windows))]
6819fn current_architecture() -> &'static str {
6820    compiled_architecture()
6821}
6822
6823fn compiled_architecture() -> &'static str {
6824    match std::env::consts::ARCH {
6825        "x86_64" => "x64",
6826        "x86" => "x86",
6827        "aarch64" => "arm64",
6828        _ => "neutral",
6829    }
6830}
6831
6832fn current_platform() -> Option<&'static str> {
6833    if cfg!(windows) { Some("Windows.Desktop") } else { None }
6834}
6835
6836#[cfg(windows)]
6837fn current_os_version() -> Option<String> {
6838    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
6839    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
6840    let build: String = key.get_value("CurrentBuildNumber").ok()?;
6841    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
6842    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
6843    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
6844    Some(format!("{major}.{minor}.{build}.{ubr}"))
6845}
6846
6847#[cfg(not(windows))]
6848fn current_os_version() -> Option<String> {
6849    None
6850}
6851
6852fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
6853    let mut parsed = Vec::new();
6854    for component in version.split('.') {
6855        if component.is_empty() {
6856            continue;
6857        }
6858        parsed.push(component.parse().ok()?);
6859    }
6860
6861    if parsed.is_empty() { None } else { Some(parsed) }
6862}
6863
6864fn select_v1_version(
6865    versions: &[V1VersionRow],
6866    requested_version: Option<&str>,
6867    requested_channel: Option<&str>,
6868) -> Result<V1VersionRow> {
6869    let mut ordered = versions.to_vec();
6870    ordered.sort_by(|left, right| {
6871        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
6872    });
6873
6874    if let Some(version) = requested_version {
6875        let channel = requested_channel.unwrap_or_default();
6876        return ordered
6877            .iter()
6878            .find(|candidate| {
6879                candidate.version.eq_ignore_ascii_case(version)
6880                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
6881            })
6882            .cloned()
6883            .ok_or_else(|| anyhow!("requested version {version} was not found"));
6884    }
6885
6886    ordered
6887        .into_iter()
6888        .next()
6889        .ok_or_else(|| anyhow!("package had no versions"))
6890}
6891
6892fn select_v2_version(
6893    versions: &[PackageVersionDataEntry],
6894    requested_version: Option<&str>,
6895) -> Result<PackageVersionDataEntry> {
6896    let mut ordered = versions.to_vec();
6897    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
6898
6899    if let Some(version) = requested_version {
6900        return ordered
6901            .iter()
6902            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
6903            .cloned()
6904            .ok_or_else(|| anyhow!("requested version {version} was not found"));
6905    }
6906
6907    ordered
6908        .into_iter()
6909        .next()
6910        .ok_or_else(|| anyhow!("package had no V2 versions"))
6911}
6912
6913fn select_rest_version<'a>(
6914    versions: &'a [VersionKey],
6915    requested_version: Option<&str>,
6916    requested_channel: Option<&str>,
6917) -> Result<&'a VersionKey> {
6918    if let Some(version) = requested_version {
6919        let channel = requested_channel.unwrap_or_default();
6920        return versions
6921            .iter()
6922            .find(|candidate| {
6923                candidate.version.eq_ignore_ascii_case(version)
6924                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
6925            })
6926            .ok_or_else(|| anyhow!("requested version {version} was not found"));
6927    }
6928
6929    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
6930}
6931
6932fn sort_versions_desc(versions: &mut [VersionKey]) {
6933    versions.sort_by(|left, right| {
6934        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
6935    });
6936}
6937
6938fn compare_version_and_channel(
6939    left_version: &str,
6940    left_channel: &str,
6941    right_version: &str,
6942    right_channel: &str,
6943) -> Ordering {
6944    compare_version(left_version, right_version).then_with(|| {
6945        left_channel
6946            .to_ascii_lowercase()
6947            .cmp(&right_channel.to_ascii_lowercase())
6948    })
6949}
6950
6951fn compare_version(left: &str, right: &str) -> Ordering {
6952    let left_parts = tokenize_version(left);
6953    let right_parts = tokenize_version(right);
6954    let max_len = left_parts.len().max(right_parts.len());
6955
6956    for index in 0..max_len {
6957        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
6958        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
6959        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
6960
6961        let ordering = if let Some((left_number, right_number)) = numeric {
6962            left_number.cmp(&right_number)
6963        } else {
6964            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
6965        };
6966
6967        if ordering != Ordering::Equal {
6968            return ordering;
6969        }
6970    }
6971
6972    Ordering::Equal
6973}
6974
6975fn tokenize_version(value: &str) -> Vec<String> {
6976    let mut parts = Vec::new();
6977    let mut current = String::new();
6978    let mut current_is_digit = None;
6979
6980    for ch in value.chars() {
6981        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
6982            if !current.is_empty() {
6983                parts.push(std::mem::take(&mut current));
6984            }
6985            current_is_digit = None;
6986            continue;
6987        }
6988
6989        let is_digit = ch.is_ascii_digit();
6990        match current_is_digit {
6991            Some(previous) if previous != is_digit => {
6992                parts.push(std::mem::take(&mut current));
6993                current.push(ch);
6994                current_is_digit = Some(is_digit);
6995            }
6996            Some(_) => current.push(ch),
6997            None => {
6998                current.push(ch);
6999                current_is_digit = Some(is_digit);
7000            }
7001        }
7002    }
7003
7004    if !current.is_empty() {
7005        parts.push(current);
7006    }
7007
7008    parts
7009}
7010
7011fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
7012    if !hash_matches(Some(expected_hash), bytes) {
7013        bail!("downloaded file hash did not match expected SHA256");
7014    }
7015    Ok(())
7016}
7017
7018fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
7019    let Some(expected_hash) = expected_hash else {
7020        return Ok(());
7021    };
7022    if ignore_security_hash {
7023        return Ok(());
7024    }
7025
7026    if !hash_matches(Some(expected_hash), bytes) {
7027        let actual = sha256_hex(bytes);
7028        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
7029    }
7030    Ok(())
7031}
7032
7033fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
7034    expected_hash
7035        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
7036        .unwrap_or(true)
7037}
7038
7039fn sha256_hex(bytes: &[u8]) -> String {
7040    let digest = Sha256::digest(bytes);
7041    let mut output = String::with_capacity(digest.len() * 2);
7042    for byte in digest {
7043        output.push_str(&format!("{byte:02X}"));
7044    }
7045    output
7046}
7047
7048fn pins_db_path(app_root: &Path) -> PathBuf {
7049    if uses_packaged_layout(app_root) {
7050        app_root.join("pinning.db")
7051    } else {
7052        app_root.join("pins.db")
7053    }
7054}
7055
7056fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
7057    let mut stmt = conn.prepare("PRAGMA table_info(pin)")?;
7058    let mut has_current_column = false;
7059    let mut has_legacy_column = false;
7060
7061    let column_names = stmt.query_map([], |row| row.get::<_, String>(1))?;
7062    for column_name in column_names {
7063        match column_name?.as_str() {
7064            "type" => has_current_column = true,
7065            "pin_type" => has_legacy_column = true,
7066            _ => {}
7067        }
7068    }
7069
7070    Ok(if has_current_column {
7071        Some("type")
7072    } else if has_legacy_column {
7073        Some("pin_type")
7074    } else {
7075        None
7076    })
7077}
7078
7079fn decode_pin_type(pin_type_int: i64) -> PinType {
7080    match pin_type_int {
7081        4 => PinType::Blocking,
7082        3 => PinType::Gating,
7083        _ => PinType::Pinning,
7084    }
7085}
7086
7087#[cfg(windows)]
7088fn dispatch_installer(
7089    installer_path: &Path,
7090    installer_type: &str,
7091    request: &InstallRequest,
7092    manifest: &Manifest,
7093    installer: &Installer,
7094) -> Result<i32> {
7095    use std::process::Command;
7096
7097    match installer_type {
7098        "msi" | "wix" => {
7099            let mut cmd = Command::new("msiexec");
7100            cmd.args(installer_command_arguments(
7101                installer_type,
7102                request,
7103                manifest,
7104                installer_path,
7105                installer,
7106            ));
7107            let status = cmd.status().context("failed to run msiexec")?;
7108            Ok(status.code().unwrap_or(-1))
7109        }
7110        "msix" | "appx" => {
7111            let mut cmd = Command::new("powershell");
7112            cmd.arg("-NoProfile")
7113                .arg("-Command")
7114                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7115            let status = cmd.status().context("failed to run Add-AppxPackage")?;
7116            Ok(status.code().unwrap_or(-1))
7117        }
7118        "zip" => {
7119            // A `zip` at the top-level installer position is treated as portable when
7120            // `NestedInstallerType=portable` is specified — that's the shape every
7121            // portable in the winget catalog uses (e.g. BurntSushi.ripgrep.MSVC).
7122            // Anything else still falls through to the historical extract-to-Programs
7123            // behavior, which isn't really correct but at least doesn't regress.
7124            if installer
7125                .nested_installer_type
7126                .as_deref()
7127                .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7128            {
7129                return install_portable(installer_path, request, manifest, installer);
7130            }
7131            let target = dirs::data_local_dir()
7132                .unwrap_or_else(|| PathBuf::from("."))
7133                .join("Programs");
7134            fs::create_dir_all(&target)?;
7135            let file = fs::File::open(installer_path)?;
7136            let mut archive = ZipArchive::new(file)?;
7137            archive.extract(&target)?;
7138            Ok(0)
7139        }
7140        "portable" => install_portable(installer_path, request, manifest, installer),
7141        // exe, inno, nullsoft, burn, etc.
7142        _ => {
7143            let mut cmd = Command::new(installer_path);
7144            cmd.args(installer_command_arguments(
7145                installer_type,
7146                request,
7147                manifest,
7148                installer_path,
7149                installer,
7150            ));
7151            let status = cmd.status().context("failed to run installer")?;
7152            Ok(status.code().unwrap_or(-1))
7153        }
7154    }
7155}
7156
7157#[cfg(not(windows))]
7158fn dispatch_installer(
7159    _installer_path: &Path,
7160    _installer_type: &str,
7161    _request: &InstallRequest,
7162    _manifest: &Manifest,
7163    _installer: &Installer,
7164) -> Result<i32> {
7165    bail!("Installing packages is only supported on Windows")
7166}
7167
7168#[cfg(any(windows, test))]
7169fn installer_command_arguments(
7170    installer_type: &str,
7171    request: &InstallRequest,
7172    _manifest: &Manifest,
7173    installer_path: &Path,
7174    installer: &Installer,
7175) -> Vec<String> {
7176    if let Some(override_args) = request.override_args.as_deref() {
7177        return split_installer_switches(override_args);
7178    }
7179
7180    let mut args = Vec::new();
7181    match installer_type {
7182        "msi" | "wix" => {
7183            args.push("/i".to_owned());
7184            args.push(installer_path.display().to_string());
7185        }
7186        "msix" | "appx" | "zip" => return Vec::new(),
7187        _ => {}
7188    }
7189
7190    let configured = match request.mode {
7191        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
7192        InstallerMode::SilentWithProgress => installer
7193            .switches
7194            .silent_with_progress
7195            .as_deref()
7196            .or(installer.switches.silent.as_deref()),
7197        InstallerMode::Silent => installer
7198            .switches
7199            .silent
7200            .as_deref()
7201            .or(installer.switches.silent_with_progress.as_deref()),
7202    };
7203
7204    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
7205        args.extend(split_installer_switches(value));
7206    } else {
7207        args.extend(default_installer_arguments(installer_type, request.mode));
7208    }
7209
7210    append_switches(
7211        &mut args,
7212        resolve_template_switch(
7213            installer.switches.log.as_deref(),
7214            default_log_switch(installer_type),
7215            request.log_path.as_ref().map(|path| path.display().to_string()),
7216            "<LOGPATH>",
7217        ),
7218    );
7219    append_switches(&mut args, installer.switches.custom.clone());
7220    append_switches(&mut args, request.custom.clone());
7221    append_switches(
7222        &mut args,
7223        resolve_template_switch(
7224            installer.switches.install_location.as_deref(),
7225            default_install_location_switch(installer_type),
7226            request.install_location.clone(),
7227            "<INSTALLPATH>",
7228        ),
7229    );
7230    args
7231}
7232
7233#[cfg(any(windows, test))]
7234fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
7235    match mode {
7236        InstallerMode::Interactive => Vec::new(),
7237        InstallerMode::SilentWithProgress => match installer_type {
7238            "inno" => vec![
7239                "/SP-".to_owned(),
7240                "/SILENT".to_owned(),
7241                "/SUPPRESSMSGBOXES".to_owned(),
7242                "/NORESTART".to_owned(),
7243            ],
7244            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
7245            "nullsoft" | "nsis" => vec!["/S".to_owned()],
7246            _ => vec!["/SILENT".to_owned()],
7247        },
7248        InstallerMode::Silent => match installer_type {
7249            "inno" => vec![
7250                "/SP-".to_owned(),
7251                "/VERYSILENT".to_owned(),
7252                "/SUPPRESSMSGBOXES".to_owned(),
7253                "/NORESTART".to_owned(),
7254            ],
7255            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
7256            "nullsoft" | "nsis" => vec!["/S".to_owned()],
7257            _ => vec!["/S".to_owned()],
7258        },
7259    }
7260}
7261
7262#[cfg(any(windows, test))]
7263fn default_log_switch(installer_type: &str) -> Option<&'static str> {
7264    match installer_type {
7265        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
7266        "inno" => Some("/LOG=\"<LOGPATH>\""),
7267        _ => None,
7268    }
7269}
7270
7271#[cfg(any(windows, test))]
7272fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
7273    match installer_type {
7274        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
7275        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
7276        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
7277        _ => None,
7278    }
7279}
7280
7281#[cfg(any(windows, test))]
7282fn resolve_template_switch(
7283    manifest_value: Option<&str>,
7284    fallback: Option<&str>,
7285    replacement: Option<String>,
7286    token: &str,
7287) -> Option<String> {
7288    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
7289    let replacement = replacement?;
7290    Some(template.replace(token, &replacement))
7291}
7292
7293#[cfg(any(windows, test))]
7294fn append_switches(args: &mut Vec<String>, value: Option<String>) {
7295    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
7296        args.extend(split_installer_switches(&value));
7297    }
7298}
7299
7300#[cfg(any(windows, test))]
7301fn split_installer_switches(value: &str) -> Vec<String> {
7302    let mut args = Vec::new();
7303    let mut current = String::new();
7304    let mut in_quotes = false;
7305
7306    for ch in value.chars() {
7307        match ch {
7308            '"' => in_quotes = !in_quotes,
7309            c if c.is_whitespace() && !in_quotes => {
7310                if !current.is_empty() {
7311                    args.push(std::mem::take(&mut current));
7312                }
7313            }
7314            _ => current.push(ch),
7315        }
7316    }
7317
7318    if !current.is_empty() {
7319        args.push(current);
7320    }
7321
7322    args
7323}
7324
7325#[cfg(windows)]
7326fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7327    if request.purge && request.preserve {
7328        bail!("--purge and --preserve cannot be used together.");
7329    }
7330
7331    if installed
7332        .installer_category
7333        .as_deref()
7334        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7335    {
7336        return uninstall_portable(installed, request);
7337    }
7338
7339    if (request.purge || request.preserve) && !request.force {
7340        bail!("--purge and --preserve are currently only supported for portable packages.");
7341    }
7342
7343    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
7344        return Ok(exit_code);
7345    }
7346
7347    if let Some(exit_code) = try_uninstall_msix(installed)? {
7348        return Ok(exit_code);
7349    }
7350
7351    bail!(
7352        "No uninstall command found for installed package '{}' ({})",
7353        installed.name,
7354        installed.local_id
7355    );
7356}
7357
7358#[cfg(windows)]
7359fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
7360    use std::os::windows::process::CommandExt;
7361    use std::process::Command;
7362
7363    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7364    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
7365
7366    let arp_paths = [
7367        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
7368        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
7369    ];
7370
7371    for root in [&hklm, &hkcu] {
7372        for arp_path in &arp_paths {
7373            if let Ok(key) = root.open_subkey(arp_path) {
7374                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
7375                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
7376                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
7377                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
7378                        if !registry_entry_matches_installed_package(
7379                            &subkey_name,
7380                            &display_name,
7381                            product_code.as_deref(),
7382                            installed,
7383                        ) {
7384                            continue;
7385                        }
7386
7387                        if let Some(exit_code) =
7388                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
7389                        {
7390                            return Ok(Some(exit_code));
7391                        }
7392
7393                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
7394                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
7395                            subkey.get_value("UninstallString").ok()
7396                        } else {
7397                            quiet_uninstall_cmd
7398                                .clone()
7399                                .or_else(|| subkey.get_value("UninstallString").ok())
7400                        }
7401                        .context("No uninstall command found in registry")?;
7402
7403                        if is_winget_uninstall_command(&uninstall_cmd)
7404                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
7405                        {
7406                            return Ok(Some(exit_code));
7407                        }
7408
7409                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
7410                        let uninstall_line = build_uninstall_command_with_mode(
7411                            &uninstall_cmd,
7412                            request.mode,
7413                            quiet_uninstall_cmd.is_some(),
7414                            log_path.as_deref(),
7415                        );
7416                        // `UninstallString` is a complete command line and may already contain
7417                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
7418                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
7419                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
7420                        // rejects with "is not recognized as an internal or external command".
7421                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
7422                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
7423                        // automatic quoting so we can feed cmd the literal command line.
7424                        let mut cmd = Command::new("cmd");
7425                        cmd.raw_arg("/S /C");
7426                        cmd.raw_arg(format!("\"{uninstall_line}\""));
7427                        let status = cmd.status().context("failed to run uninstaller")?;
7428                        return Ok(Some(status.code().unwrap_or(-1)));
7429                    }
7430                }
7431            }
7432        }
7433    }
7434
7435    Ok(None)
7436}
7437
7438#[cfg(windows)]
7439fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
7440    use std::process::Command;
7441
7442    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
7443        return Ok(None);
7444    }
7445
7446    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
7447    let mut cmd = Command::new("powershell");
7448    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
7449        package_full_name,
7450        &installed.package_family_names,
7451    ));
7452    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
7453    Ok(Some(status.code().unwrap_or(-1)))
7454}
7455
7456#[cfg(windows)]
7457fn arp_subkey_name(local_id: &str) -> Option<&str> {
7458    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
7459}
7460
7461#[cfg(windows)]
7462fn registry_entry_matches_installed_package(
7463    subkey_name: &str,
7464    display_name: &str,
7465    product_code: Option<&str>,
7466    installed: &ListMatch,
7467) -> bool {
7468    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
7469        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
7470    {
7471        return true;
7472    }
7473
7474    if installed.product_codes.iter().any(|code| {
7475        code.eq_ignore_ascii_case(subkey_name)
7476            || product_code
7477                .map(|value| code.eq_ignore_ascii_case(value))
7478                .unwrap_or(false)
7479    }) {
7480        return true;
7481    }
7482
7483    display_name.eq_ignore_ascii_case(&installed.name)
7484}
7485
7486#[cfg(all(windows, test))]
7487fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
7488    build_uninstall_command_with_mode(
7489        uninstall_cmd,
7490        if silent {
7491            InstallerMode::Silent
7492        } else {
7493            InstallerMode::Interactive
7494        },
7495        has_quiet_command,
7496        None,
7497    )
7498}
7499
7500#[cfg(windows)]
7501fn build_uninstall_command_with_mode(
7502    uninstall_cmd: &str,
7503    mode: InstallerMode,
7504    has_quiet_command: bool,
7505    log_path: Option<&str>,
7506) -> String {
7507    let uninstall_cmd = log_path
7508        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
7509        .unwrap_or_else(|| uninstall_cmd.to_owned());
7510    if mode == InstallerMode::Interactive || has_quiet_command {
7511        return uninstall_cmd;
7512    }
7513
7514    let lower = uninstall_cmd.to_ascii_lowercase();
7515    if is_winget_uninstall_command_lower(&lower) {
7516        return uninstall_cmd;
7517    }
7518    if lower.contains("/quiet")
7519        || lower.contains("/passive")
7520        || lower.contains("/verysilent")
7521        || lower.contains("/silent")
7522        || lower.contains(" /s")
7523    {
7524        return uninstall_cmd;
7525    }
7526
7527    format!("{uninstall_cmd} /S")
7528}
7529
7530#[cfg(windows)]
7531fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
7532    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
7533        return Ok(None);
7534    };
7535
7536    let exit_code = run_winget_uninstall(&args)?;
7537    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
7538        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
7539            return Ok(Some(exit_code));
7540        };
7541
7542        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
7543    }
7544
7545    Ok(Some(exit_code))
7546}
7547
7548#[cfg(windows)]
7549fn run_winget_uninstall(args: &[String]) -> Result<i32> {
7550    use std::process::Command;
7551
7552    let status = Command::new("winget")
7553        .args(args)
7554        .status()
7555        .context("failed to run delegated winget uninstall")?;
7556    Ok(status.code().unwrap_or(-1))
7557}
7558
7559#[cfg(windows)]
7560fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
7561    build_winget_uninstall_arguments_with_scope(installed, request, true)
7562}
7563
7564#[cfg(windows)]
7565fn build_winget_uninstall_arguments_with_scope(
7566    installed: &ListMatch,
7567    request: &UninstallRequest,
7568    include_scope: bool,
7569) -> Option<Vec<String>> {
7570    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
7571        return None;
7572    }
7573
7574    let mut args = vec![
7575        "uninstall".to_owned(),
7576        "--id".to_owned(),
7577        installed.id.clone(),
7578        "--exact".to_owned(),
7579        "--disable-interactivity".to_owned(),
7580    ];
7581
7582    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
7583        args.push("--source".to_owned());
7584        args.push(source_name.to_owned());
7585    }
7586
7587    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
7588        args.push("--scope".to_owned());
7589        args.push(scope.to_owned());
7590    }
7591
7592    if request.mode == InstallerMode::Silent {
7593        args.push("--silent".to_owned());
7594    }
7595
7596    Some(args)
7597}
7598
7599#[cfg(windows)]
7600fn is_winget_uninstall_command(command: &str) -> bool {
7601    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
7602}
7603
7604#[cfg(windows)]
7605fn is_winget_uninstall_command_lower(command: &str) -> bool {
7606    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
7607}
7608
7609#[cfg(windows)]
7610fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
7611    let full_name_literal = package_full_name
7612        .map(|value| format!("'{}'", value.replace('\'', "''")))
7613        .unwrap_or_else(|| "$null".to_owned());
7614    let family_names_literal = if package_family_names.is_empty() {
7615        "@()".to_owned()
7616    } else {
7617        format!(
7618            "@({})",
7619            package_family_names
7620                .iter()
7621                .map(|value| format!("'{}'", value.replace('\'', "''")))
7622                .collect::<Vec<_>>()
7623                .join(", ")
7624        )
7625    };
7626
7627    format!(
7628        "$fullName = {full_name_literal}; \
7629         $familyNames = {family_names_literal}; \
7630         $targets = Get-AppxPackage | Where-Object {{ \
7631         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
7632         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
7633         if (-not $targets) {{ exit 1 }}; \
7634         $targets | Remove-AppxPackage"
7635    )
7636}
7637
7638#[cfg(windows)]
7639fn try_run_msi_uninstall(
7640    installed: &ListMatch,
7641    subkey_name: &str,
7642    product_code: Option<&str>,
7643    request: &UninstallRequest,
7644) -> Result<Option<i32>> {
7645    use std::process::Command;
7646
7647    let uninstall_code = installed
7648        .product_codes
7649        .iter()
7650        .map(String::as_str)
7651        .chain(product_code)
7652        .chain(Some(subkey_name))
7653        .find(|value| is_product_code_like(value));
7654
7655    let Some(uninstall_code) = uninstall_code else {
7656        return Ok(None);
7657    };
7658
7659    let mut cmd = Command::new("msiexec");
7660    cmd.arg("/x").arg(uninstall_code);
7661    match request.mode {
7662        InstallerMode::Interactive => {}
7663        InstallerMode::SilentWithProgress => {
7664            cmd.arg("/passive").arg("/norestart");
7665        }
7666        InstallerMode::Silent => {
7667            cmd.arg("/quiet").arg("/norestart");
7668        }
7669    }
7670    if let Some(log_path) = &request.log_path {
7671        cmd.arg("/log").arg(log_path);
7672    }
7673
7674    let status = cmd.status().context("failed to run msiexec uninstall")?;
7675    Ok(Some(status.code().unwrap_or(-1)))
7676}
7677
7678#[cfg(windows)]
7679fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7680    // Read PortableSymlinkFullPath from the ARP entry *before* we touch the
7681    // registry, so we can remove winget-created shims in Links\ if any. Without
7682    // this, a portable that winget originally installed (with a
7683    // PortableCommandAlias set in its manifest) would leave a dangling symlink
7684    // in %LOCALAPPDATA%\Microsoft\WinGet\Links\ once pinget uninstalls it.
7685    let known_symlink = read_portable_symlink_full_path(installed);
7686
7687    let Some(location) = installed.install_location.as_deref() else {
7688        if request.force {
7689            try_remove_portable_symlinks(installed, &known_symlink, None);
7690            try_remove_portable_registry_entry(installed);
7691            return Ok(0);
7692        }
7693        bail!(
7694            "Portable package '{}' does not expose an install location.",
7695            installed.name
7696        );
7697    };
7698
7699    if request.preserve {
7700        return Ok(0);
7701    }
7702
7703    let path = Path::new(location);
7704    let removed = if path.is_dir() {
7705        fs::remove_dir_all(path)?;
7706        true
7707    } else if path.is_file() {
7708        fs::remove_file(path)?;
7709        true
7710    } else {
7711        false
7712    };
7713
7714    if removed || request.force {
7715        // Once the files are gone (or the user forced past missing files), drop
7716        // the symlink shim and ARP entry too so the package fully disappears.
7717        try_remove_portable_symlinks(installed, &known_symlink, Some(path));
7718        try_remove_portable_registry_entry(installed);
7719        return Ok(0);
7720    }
7721
7722    bail!("Portable package location not found: {location}")
7723}
7724
7725/// Reads `PortableSymlinkFullPath` from the ARP subkey backing `installed`
7726/// when present. This value is recorded for portable installs that created a
7727/// `Links\<alias>.exe` symlink, including pinget-installed entries when
7728/// `install_portable` created that link. Entries without a created/recorded
7729/// portable symlink return `None`.
7730#[cfg(windows)]
7731fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
7732    const ARP_PREFIX: &str = r"ARP\";
7733    if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
7734        let mut parts = rest.splitn(3, '\\');
7735        let scope = parts.next().unwrap_or("");
7736        let _arch = parts.next();
7737        let subkey_name = parts.next().unwrap_or("");
7738        if subkey_name.is_empty() {
7739            return None;
7740        }
7741        let hive = if scope.eq_ignore_ascii_case("User") {
7742            RegKey::predef(HKEY_CURRENT_USER)
7743        } else {
7744            RegKey::predef(HKEY_LOCAL_MACHINE)
7745        };
7746        let subkey = hive
7747            .open_subkey(format!(
7748                r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
7749            ))
7750            .ok()?;
7751        return read_reg_string(&subkey, "PortableSymlinkFullPath");
7752    }
7753    None
7754}
7755
7756/// Removes the symlink shims winget would have created for this portable.
7757/// Tries (1) the exact path from `PortableSymlinkFullPath` if known, and
7758/// (2) any symlink in `%LOCALAPPDATA%\Microsoft\WinGet\Links\` whose target
7759/// resolves under `install_location` (defensive sweep for shims winget made
7760/// but didn't surface in the registry). Best-effort: failures are swallowed.
7761#[cfg(windows)]
7762fn try_remove_portable_symlinks(
7763    _installed: &ListMatch,
7764    known_symlink: &Option<String>,
7765    install_location: Option<&Path>,
7766) {
7767    if let Some(path) = known_symlink.as_deref() {
7768        let _ = fs::remove_file(path);
7769    }
7770
7771    // Defensive sweep: walk Links\ and remove shims whose stored target was
7772    // inside (or equal to) install_location. By this point install_location is
7773    // already deleted, so we compare the symlink's stored target string (which
7774    // the filesystem retains even after the target itself is gone).
7775    let Some(install_dir) = install_location else { return };
7776
7777    let Some(local) = dirs::data_local_dir() else { return };
7778    let links_root = local.join("Microsoft").join("WinGet").join("Links");
7779    let Ok(entries) = fs::read_dir(&links_root) else { return };
7780    for entry in entries.flatten() {
7781        let path = entry.path();
7782        let Ok(target) = fs::read_link(&path) else { continue };
7783        if path_starts_with_case_insensitive(&target, install_dir) {
7784            let _ = fs::remove_file(&path);
7785        }
7786    }
7787}
7788
7789/// Case-insensitive, component-aware path-prefix check for Windows. A raw
7790/// `str::starts_with` on the path strings would let `C:\Foo` falsely match
7791/// `C:\Foobar`; iterating path components avoids that. Used by the Links\
7792/// sweep so an uninstall of one portable can't accidentally delete shims that
7793/// belong to a different portable whose install dir happens to share a
7794/// string-prefix.
7795#[cfg(windows)]
7796fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
7797    let lower_components = |p: &Path| -> Vec<String> {
7798        p.components()
7799            .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
7800            .collect()
7801    };
7802    let parent_components = lower_components(parent);
7803    let child_components = lower_components(child);
7804    if parent_components.is_empty() || parent_components.len() > child_components.len() {
7805        return false;
7806    }
7807    parent_components
7808        .iter()
7809        .zip(child_components.iter())
7810        .all(|(a, b)| a == b)
7811}
7812
7813/// Best-effort removal of the HKCU/HKLM ARP subkey backing a portable entry.
7814/// We match on local_id (`ARP\<scope>\<arch>\<subkey>`) when present so we
7815/// delete the exact key the list view surfaced; otherwise we walk the standard
7816/// Uninstall path and match on WinGetPackageIdentifier. Failures are logged via
7817/// the returned warning chain in callers — we never bubble a registry-cleanup
7818/// failure as a hard error because the file deletion already succeeded.
7819#[cfg(windows)]
7820fn try_remove_portable_registry_entry(installed: &ListMatch) {
7821    // Collect (is_hkcu, subkey_path) pairs so we can re-open hives at delete
7822    // time instead of holding clones of RegKey (which doesn't implement Clone).
7823    let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
7824        // local_id format is `ARP\<scope>\<arch>\<subkey>` — pick the right hive/path.
7825        let mut parts = rest.splitn(3, '\\');
7826        let scope = parts.next().unwrap_or("");
7827        let _arch = parts.next();
7828        let subkey_name = parts.next().unwrap_or("");
7829        if subkey_name.is_empty() {
7830            return;
7831        }
7832        let is_hkcu = scope.eq_ignore_ascii_case("User");
7833        vec![(
7834            is_hkcu,
7835            format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
7836        )]
7837    } else {
7838        // Fall back to scanning by WinGetPackageIdentifier.
7839        let mut found = Vec::new();
7840        for (is_hkcu, hive) in [
7841            (true, RegKey::predef(HKEY_CURRENT_USER)),
7842            (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
7843        ] {
7844            let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
7845                continue;
7846            };
7847            for name in uninstall.enum_keys().flatten() {
7848                let Ok(subkey) = uninstall.open_subkey(&name) else {
7849                    continue;
7850                };
7851                if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
7852                    found.push((
7853                        is_hkcu,
7854                        format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
7855                    ));
7856                }
7857            }
7858        }
7859        found
7860    };
7861
7862    for (is_hkcu, path) in candidates {
7863        let hive = if is_hkcu {
7864            RegKey::predef(HKEY_CURRENT_USER)
7865        } else {
7866            RegKey::predef(HKEY_LOCAL_MACHINE)
7867        };
7868        let _ = hive.delete_subkey_all(&path);
7869    }
7870}
7871
7872#[cfg(windows)]
7873fn is_product_code_like(value: &str) -> bool {
7874    value.starts_with('{') && value.ends_with('}')
7875}
7876
7877#[cfg(not(windows))]
7878fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
7879    bail!("Uninstalling packages is only supported on Windows")
7880}
7881
7882/// Installs (or upgrades) a portable WinGet package.
7883///
7884/// Handles both `InstallerType: portable` (single binary download) and
7885/// `InstallerType: zip` with `NestedInstallerType: portable` (zip containing
7886/// the binary at a relative path), which is the shape every portable in the
7887/// community winget catalog uses today.
7888///
7889/// Behavior matches winget's portable workflow closely enough that the resulting
7890/// HKCU ARP entry is recognized by `winget list` and by pinget's own list view:
7891/// - Resolves the target install directory in this priority order:
7892///   1. `request.install_location` if provided (passed for upgrades)
7893///   2. Existing `InstallLocation` from the registry ARP entry (so upgrades
7894///      with no explicit location still preserve the user's custom path)
7895///   3. WinGet's default user portable root, `%LOCALAPPDATA%\Microsoft\WinGet
7896///      \Packages\<PackageId>_<SourceId>`
7897/// - If an existing pinget-owned install lives in that directory
7898///   (`InstallDirectoryCreated=1`), cleans its contents before extracting the
7899///   new version so leftover files from older versions don't pile up.
7900/// - Extracts the zip (or copies the standalone binary), then writes the ARP
7901///   registry entry that `winget list` and pinget's `collect_uninstall_view`
7902///   both read from.
7903#[cfg(windows)]
7904fn install_portable(
7905    installer_path: &Path,
7906    request: &InstallRequest,
7907    manifest: &Manifest,
7908    installer: &Installer,
7909) -> Result<i32> {
7910    let source_identifier = portable_source_identifier(&request.query);
7911    let existing_entry = read_existing_portable_entry(&manifest.id);
7912    // Reuse the existing subkey name on upgrade so we don't orphan the prior
7913    // entry by writing to a different one.
7914    let subkey_name = existing_entry
7915        .as_ref()
7916        .map(|entry| entry.subkey_name.clone())
7917        .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
7918
7919    let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
7920
7921    // If we created this directory on a previous install, wipe it clean so the
7922    // new version doesn't co-exist with leftovers from the previous one. We do
7923    // not touch a directory we don't own (InstallDirectoryCreated != 1), to
7924    // avoid stomping user data.
7925    let prev_dir_created = existing_entry
7926        .as_ref()
7927        .and_then(|entry| entry.install_directory_created)
7928        == Some(1);
7929    let dir_existed = target_dir.is_dir();
7930    if prev_dir_created && dir_existed {
7931        clean_directory_contents(&target_dir).with_context(|| {
7932            format!(
7933                "failed to clean existing portable install directory: {}",
7934                target_dir.display()
7935            )
7936        })?;
7937    }
7938
7939    fs::create_dir_all(&target_dir)
7940        .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
7941    // The "we created it" flag stays sticky across upgrades — if a previous
7942    // install marked it created, it still counts as created by us even when
7943    // the dir already existed this round.
7944    let install_directory_created = !dir_existed || prev_dir_created;
7945
7946    let installer_type = installer.installer_type.as_deref().unwrap_or("");
7947    let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
7948        let file = fs::File::open(installer_path)
7949            .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
7950        let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
7951        archive
7952            .extract(&target_dir)
7953            .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
7954        // For nested-portable zips the binary lives at the RelativeFilePath the
7955        // manifest declares. We record the first one as PortableTargetFullPath so
7956        // winget's portable uninstall workflow can identify which file to remove.
7957        installer
7958            .nested_installer_files
7959            .first()
7960            .map(|file| target_dir.join(&file.relative_file_path))
7961            .unwrap_or_else(|| target_dir.clone())
7962    } else {
7963        // Standalone portable: copy the downloaded file into target_dir using
7964        // its original filename. winget would optionally rename to the
7965        // PortableCommandAlias; pinget keeps the original name for now.
7966        let basename = installer_path
7967            .file_name()
7968            .ok_or_else(|| anyhow!("portable installer has no filename"))?;
7969        let copied = target_dir.join(basename);
7970        fs::copy(installer_path, &copied)
7971            .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
7972        copied
7973    };
7974
7975    // Create the Links\<alias>.exe shim so users can invoke the portable from
7976    // any shell, matching winget's portable workflow. Failures are non-fatal —
7977    // when the user lacks SeCreateSymbolicLink (no Developer Mode, not admin)
7978    // we still leave the package installed at install_location.
7979    let alias = determine_portable_alias(installer);
7980    let symlink_full_path = match alias.as_deref() {
7981        Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
7982        _ => None,
7983    };
7984
7985    // Ensure %LOCALAPPDATA%\Microsoft\WinGet\Links is on user PATH so the
7986    // symlink we just created is resolvable from new shells. Tracks whether we
7987    // added it this round so uninstall can decide whether to take it back out.
7988    let added_to_path = if symlink_full_path.is_some() {
7989        try_add_links_dir_to_user_path()
7990    } else {
7991        false
7992    };
7993
7994    write_portable_arp_entry(&PortableArpEntry {
7995        subkey_name: &subkey_name,
7996        install_location: &target_dir,
7997        portable_target_full_path: &portable_target_full_path,
7998        portable_symlink_full_path: symlink_full_path.as_deref(),
7999        install_directory_created,
8000        added_to_path,
8001        source_identifier: &source_identifier,
8002        manifest,
8003    })?;
8004
8005    Ok(0)
8006}
8007
8008#[cfg(windows)]
8009struct PortableArpEntry<'a> {
8010    subkey_name: &'a str,
8011    install_location: &'a Path,
8012    portable_target_full_path: &'a Path,
8013    portable_symlink_full_path: Option<&'a Path>,
8014    install_directory_created: bool,
8015    added_to_path: bool,
8016    source_identifier: &'a str,
8017    manifest: &'a Manifest,
8018}
8019
8020/// Picks the portable command alias from the manifest. Mirrors winget's
8021/// resolution: nested-installer files first (used by zip+portable manifests),
8022/// then the top-level `Commands` field (used by standalone portable manifests).
8023/// Returns None when the manifest doesn't declare a command — the package
8024/// still installs, just without a Links\ shim.
8025#[cfg(windows)]
8026fn determine_portable_alias(installer: &Installer) -> Option<String> {
8027    installer
8028        .nested_installer_files
8029        .iter()
8030        .find_map(|file| file.portable_command_alias.clone())
8031        .or_else(|| installer.commands.first().cloned())
8032}
8033
8034#[cfg(windows)]
8035fn winget_links_dir() -> PathBuf {
8036    dirs::data_local_dir()
8037        .unwrap_or_else(|| PathBuf::from("."))
8038        .join("Microsoft")
8039        .join("WinGet")
8040        .join("Links")
8041}
8042
8043/// Creates (or replaces) `Links\<alias>.exe` as a file symlink pointing at the
8044/// portable's binary. Returns the link path on success. Returns None and logs
8045/// a warning if the user can't create symlinks (no Developer Mode, not admin)
8046/// so the install can still complete without the shim.
8047#[cfg(windows)]
8048fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
8049    use std::os::windows::fs::symlink_file;
8050    let links_dir = winget_links_dir();
8051    if fs::create_dir_all(&links_dir).is_err() {
8052        return None;
8053    }
8054    let link_path = links_dir.join(format!("{alias}.exe"));
8055
8056    // Replace any prior link/file at the path so upgrades repoint to the new
8057    // binary instead of leaving a stale symlink behind.
8058    if link_path.symlink_metadata().is_ok() {
8059        let _ = fs::remove_file(&link_path);
8060    }
8061
8062    // Symlink creation fails with ERROR_PRIVILEGE_NOT_HELD (1314) when the
8063    // process lacks SeCreateSymbolicLink (no Developer Mode, not admin). Treat
8064    // that — and any other I/O failure — as non-fatal: the package files are
8065    // already in place, just without a Links\ shim.
8066    symlink_file(target, &link_path).ok().map(|()| link_path)
8067}
8068
8069/// Adds `Links\` to the user's HKCU\Environment\Path if not already present,
8070/// then broadcasts WM_SETTINGCHANGE so explorer-spawned shells pick up the
8071/// new PATH without a logoff. Returns true if we actually appended PATH this
8072/// call — callers should write `InstallDirectoryAddedToPath` accordingly so a
8073/// follow-up uninstall doesn't take PATH back out for other portables.
8074#[cfg(windows)]
8075fn try_add_links_dir_to_user_path() -> bool {
8076    let links_dir = winget_links_dir();
8077    let links_str = links_dir.to_string_lossy().to_string();
8078    let normalized_links = links_str.to_ascii_lowercase();
8079
8080    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8081    let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
8082        return false;
8083    };
8084    let existing: String = env_key.get_value("Path").unwrap_or_default();
8085    let already_present = existing
8086        .split(';')
8087        .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
8088    if already_present {
8089        return false;
8090    }
8091
8092    let new_path = if existing.is_empty() {
8093        links_str
8094    } else if existing.ends_with(';') {
8095        format!("{existing}{links_str}")
8096    } else {
8097        format!("{existing};{links_str}")
8098    };
8099
8100    if env_key.set_value("Path", &new_path).is_err() {
8101        return false;
8102    }
8103
8104    broadcast_environment_change();
8105    true
8106}
8107
8108#[cfg(windows)]
8109fn broadcast_environment_change() {
8110    use std::ffi::OsStr;
8111    use std::os::windows::ffi::OsStrExt;
8112
8113    use windows_sys::Win32::UI::WindowsAndMessaging::{
8114        HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
8115    };
8116
8117    let param: Vec<u16> = OsStr::new("Environment")
8118        .encode_wide()
8119        .chain(std::iter::once(0))
8120        .collect();
8121    // SAFETY: SendMessageTimeoutW only reads `param` for the duration of the
8122    // call (it copies the string before returning), and `result` is a stack
8123    // local we provide. No aliasing or lifetime issues.
8124    unsafe {
8125        let mut result: usize = 0;
8126        SendMessageTimeoutW(
8127            HWND_BROADCAST,
8128            WM_SETTINGCHANGE,
8129            0usize,
8130            param.as_ptr() as isize,
8131            SMTO_ABORTIFHUNG,
8132            5000,
8133            &mut result,
8134        );
8135    }
8136}
8137
8138/// Source identifier embedded in the ARP subkey name. We mirror winget for
8139/// the community repo so winget's UninstallString can resolve the subkey and
8140/// `winget list` still finds the package; everything else uses the source
8141/// name verbatim.
8142#[cfg(windows)]
8143fn portable_source_identifier(query: &PackageQuery) -> String {
8144    match query.source.as_deref() {
8145        Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8146        Some(name) if !name.is_empty() => name.to_owned(),
8147        _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8148    }
8149}
8150
8151#[cfg(windows)]
8152fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
8153    format!("{package_id}_{source_identifier}")
8154}
8155
8156#[cfg(windows)]
8157struct ExistingPortableEntry {
8158    install_location: Option<PathBuf>,
8159    install_directory_created: Option<u32>,
8160    subkey_name: String,
8161}
8162
8163#[cfg(windows)]
8164fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
8165    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8166    let uninstall = hkcu
8167        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
8168        .ok()?;
8169    for name in uninstall.enum_keys().flatten() {
8170        let Ok(subkey) = uninstall.open_subkey(&name) else {
8171            continue;
8172        };
8173        if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
8174            continue;
8175        }
8176        if !read_reg_string(&subkey, "WinGetInstallerType")
8177            .as_deref()
8178            .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8179        {
8180            continue;
8181        }
8182        return Some(ExistingPortableEntry {
8183            install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
8184            install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
8185            subkey_name: name,
8186        });
8187    }
8188    None
8189}
8190
8191#[cfg(windows)]
8192fn resolve_portable_install_location(
8193    request: &InstallRequest,
8194    existing: &Option<ExistingPortableEntry>,
8195    subkey_name: &str,
8196) -> PathBuf {
8197    resolve_portable_install_location_pure(
8198        request.install_location.as_deref(),
8199        existing
8200            .as_ref()
8201            .and_then(|e| e.install_location.as_deref())
8202            .and_then(Path::to_str),
8203        subkey_name,
8204        &default_user_portable_root(),
8205    )
8206}
8207
8208/// Pure resolution rule for the portable install directory. Priority:
8209/// `request_location` (CLI override) → `existing_location` (so upgrades
8210/// preserve a winget-installed location) → default user portable root joined
8211/// with the package's ARP subkey name.
8212#[cfg(windows)]
8213fn resolve_portable_install_location_pure(
8214    request_location: Option<&str>,
8215    existing_location: Option<&str>,
8216    subkey_name: &str,
8217    default_user_portable_root: &Path,
8218) -> PathBuf {
8219    if let Some(custom) = request_location
8220        && !custom.is_empty()
8221    {
8222        return PathBuf::from(custom);
8223    }
8224    if let Some(loc) = existing_location
8225        && !loc.is_empty()
8226    {
8227        return PathBuf::from(loc);
8228    }
8229    default_user_portable_root.join(subkey_name)
8230}
8231
8232#[cfg(windows)]
8233fn default_user_portable_root() -> PathBuf {
8234    dirs::data_local_dir()
8235        .unwrap_or_else(|| PathBuf::from("."))
8236        .join("Microsoft")
8237        .join("WinGet")
8238        .join("Packages")
8239}
8240
8241#[cfg(windows)]
8242fn clean_directory_contents(dir: &Path) -> Result<()> {
8243    if !dir.is_dir() {
8244        return Ok(());
8245    }
8246    for entry in fs::read_dir(dir)? {
8247        let entry = entry?;
8248        let path = entry.path();
8249        if path.is_dir() {
8250            fs::remove_dir_all(&path)?;
8251        } else {
8252            fs::remove_file(&path)?;
8253        }
8254    }
8255    Ok(())
8256}
8257
8258/// Writes the HKCU ARP entry winget would normally write for a portable
8259/// install. Only the subset that winget's portable list view and pinget's
8260/// `collect_uninstall_view` actually read.
8261#[cfg(windows)]
8262fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
8263    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8264    let uninstall_path = format!(
8265        r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
8266        entry.subkey_name
8267    );
8268    let (subkey, _) = hkcu
8269        .create_subkey(&uninstall_path)
8270        .context("failed to create portable ARP registry subkey")?;
8271
8272    let manifest = entry.manifest;
8273    let install_location_str = entry.install_location.to_string_lossy().to_string();
8274    subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
8275    subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
8276    subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
8277    subkey.set_value("InstallLocation", &install_location_str)?;
8278    subkey.set_value(
8279        "PortableTargetFullPath",
8280        &entry.portable_target_full_path.to_string_lossy().to_string(),
8281    )?;
8282    if let Some(symlink_path) = entry.portable_symlink_full_path {
8283        subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
8284    }
8285    subkey.set_value(
8286        "InstallDirectoryAddedToPath",
8287        &(if entry.added_to_path { 1u32 } else { 0u32 }),
8288    )?;
8289    subkey.set_value(
8290        "InstallDirectoryCreated",
8291        &(if entry.install_directory_created { 1u32 } else { 0u32 }),
8292    )?;
8293    subkey.set_value(
8294        "DisplayName",
8295        &if manifest.name.is_empty() {
8296            manifest.id.clone()
8297        } else {
8298            manifest.name.clone()
8299        },
8300    )?;
8301    subkey.set_value("DisplayVersion", &manifest.version)?;
8302    if let Some(publisher) = manifest.publisher.as_ref() {
8303        subkey.set_value("Publisher", publisher)?;
8304    }
8305    subkey.set_value(
8306        "UninstallString",
8307        &format!("winget uninstall --product-code {}", entry.subkey_name),
8308    )?;
8309    let today = Utc::now().format("%Y%m%d").to_string();
8310    subkey.set_value("InstallDate", &today)?;
8311    if let Some(url) = manifest.package_url.as_ref() {
8312        subkey.set_value("URLInfoAbout", url)?;
8313    }
8314
8315    Ok(())
8316}
8317
8318#[cfg(test)]
8319mod tests {
8320    use std::io::Write;
8321    use std::path::{Path, PathBuf};
8322
8323    use flate2::Compression;
8324    use flate2::write::DeflateEncoder;
8325
8326    use super::*;
8327
8328    fn temp_app_root(label: &str) -> PathBuf {
8329        std::env::temp_dir().join(format!(
8330            "pinget-rs-tests-{label}-{}",
8331            Utc::now().timestamp_nanos_opt().unwrap_or_default()
8332        ))
8333    }
8334
8335    #[cfg(windows)]
8336    #[test]
8337    fn path_prefix_check_rejects_string_prefix_collisions() {
8338        // Regression: a naive str::starts_with would falsely consider
8339        // C:\Foobar a child of C:\Foo and let the Links\ sweep delete unrelated
8340        // symlinks. The component-aware check must reject these.
8341        assert!(!path_starts_with_case_insensitive(
8342            Path::new(r"C:\Foobar\bin.exe"),
8343            Path::new(r"C:\Foo"),
8344        ));
8345        assert!(!path_starts_with_case_insensitive(
8346            Path::new(r"C:\Foo\bin.exe.bak"),
8347            Path::new(r"C:\Foo\bin.exe"),
8348        ));
8349    }
8350
8351    #[cfg(windows)]
8352    #[test]
8353    fn path_prefix_check_accepts_real_children_and_self() {
8354        // Dir install: the symlink target lives inside the install dir.
8355        assert!(path_starts_with_case_insensitive(
8356            Path::new(r"C:\Foo\sub\bin.exe"),
8357            Path::new(r"C:\Foo"),
8358        ));
8359        // File install: the symlink points at the same file we removed.
8360        assert!(path_starts_with_case_insensitive(
8361            Path::new(r"C:\Foo\bin.exe"),
8362            Path::new(r"C:\Foo\bin.exe"),
8363        ));
8364    }
8365
8366    #[cfg(windows)]
8367    #[test]
8368    fn path_prefix_check_is_case_insensitive() {
8369        // Windows paths are case-insensitive — winget and pinget may write
8370        // them with different casing.
8371        assert!(path_starts_with_case_insensitive(
8372            Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
8373            Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
8374        ));
8375    }
8376
8377    #[cfg(windows)]
8378    #[test]
8379    fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
8380        let query = PackageQuery {
8381            source: Some("winget".to_owned()),
8382            ..PackageQuery::default()
8383        };
8384        assert_eq!(
8385            portable_source_identifier(&query),
8386            "Microsoft.Winget.Source_8wekyb3d8bbwe"
8387        );
8388        // Case-insensitive — manifests/catalog may report differently.
8389        let query = PackageQuery {
8390            source: Some("WINGET".to_owned()),
8391            ..PackageQuery::default()
8392        };
8393        assert_eq!(
8394            portable_source_identifier(&query),
8395            "Microsoft.Winget.Source_8wekyb3d8bbwe"
8396        );
8397    }
8398
8399    #[cfg(windows)]
8400    #[test]
8401    fn portable_source_identifier_passes_through_custom_sources() {
8402        let query = PackageQuery {
8403            source: Some("internal-feed".to_owned()),
8404            ..PackageQuery::default()
8405        };
8406        assert_eq!(portable_source_identifier(&query), "internal-feed");
8407    }
8408
8409    #[cfg(windows)]
8410    #[test]
8411    fn portable_source_identifier_defaults_for_missing_or_empty_source() {
8412        let query = PackageQuery {
8413            source: None,
8414            ..PackageQuery::default()
8415        };
8416        assert_eq!(
8417            portable_source_identifier(&query),
8418            "Microsoft.Winget.Source_8wekyb3d8bbwe"
8419        );
8420        let query = PackageQuery {
8421            source: Some(String::new()),
8422            ..PackageQuery::default()
8423        };
8424        assert_eq!(
8425            portable_source_identifier(&query),
8426            "Microsoft.Winget.Source_8wekyb3d8bbwe"
8427        );
8428    }
8429
8430    #[cfg(windows)]
8431    #[test]
8432    fn portable_subkey_name_matches_winget_format() {
8433        assert_eq!(
8434            portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
8435            "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
8436        );
8437    }
8438
8439    #[cfg(windows)]
8440    #[test]
8441    fn determine_portable_alias_prefers_nested_installer_alias() {
8442        let installer = Installer {
8443            nested_installer_files: vec![NestedInstallerFile {
8444                relative_file_path: "ripgrep/rg.exe".to_owned(),
8445                portable_command_alias: Some("rg".to_owned()),
8446            }],
8447            commands: vec!["other-name".to_owned()],
8448            ..Installer::default()
8449        };
8450        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
8451    }
8452
8453    #[cfg(windows)]
8454    #[test]
8455    fn determine_portable_alias_falls_back_to_commands() {
8456        let installer = Installer {
8457            // No nested files at all (standalone portable case).
8458            commands: vec!["nuget".to_owned()],
8459            ..Installer::default()
8460        };
8461        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
8462
8463        // Nested files exist but none declare PortableCommandAlias.
8464        let installer = Installer {
8465            nested_installer_files: vec![NestedInstallerFile {
8466                relative_file_path: "subdir/bin.exe".to_owned(),
8467                portable_command_alias: None,
8468            }],
8469            commands: vec!["bin".to_owned()],
8470            ..Installer::default()
8471        };
8472        assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
8473    }
8474
8475    #[cfg(windows)]
8476    #[test]
8477    fn determine_portable_alias_returns_none_without_alias_or_commands() {
8478        let installer = Installer::default();
8479        assert!(determine_portable_alias(&installer).is_none());
8480    }
8481
8482    #[cfg(windows)]
8483    #[test]
8484    fn resolve_portable_install_location_prefers_request_override() {
8485        let result = resolve_portable_install_location_pure(
8486            Some(r"D:\Tools\rg"),
8487            Some(r"C:\Software\rg-test"),
8488            "Sub_Source",
8489            Path::new(r"C:\default"),
8490        );
8491        assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
8492    }
8493
8494    #[cfg(windows)]
8495    #[test]
8496    fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
8497        // The #4823 scenario: pinget upgrade with no explicit --location must
8498        // resolve to the install_location the prior install recorded, not the
8499        // default portable root.
8500        let result = resolve_portable_install_location_pure(
8501            None,
8502            Some(r"C:\Software\rg-test"),
8503            "Sub_Source",
8504            Path::new(r"C:\default"),
8505        );
8506        assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
8507    }
8508
8509    #[cfg(windows)]
8510    #[test]
8511    fn resolve_portable_install_location_falls_back_to_default_root() {
8512        let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
8513        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
8514    }
8515
8516    #[cfg(windows)]
8517    #[test]
8518    fn resolve_portable_install_location_treats_empty_strings_as_unset() {
8519        // Defensive: PackageQuery / registry reads can yield "" instead of
8520        // None for missing values.
8521        let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
8522        assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
8523    }
8524
8525    #[cfg(windows)]
8526    #[test]
8527    fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
8528        let root = std::env::temp_dir().join(format!(
8529            "pinget-clean-test-{}",
8530            Utc::now().timestamp_nanos_opt().unwrap_or_default()
8531        ));
8532        fs::create_dir_all(&root).expect("create temp dir");
8533        fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
8534        fs::create_dir_all(root.join("nested")).expect("create nested dir");
8535        fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
8536
8537        clean_directory_contents(&root).expect("clean root contents");
8538
8539        assert!(root.is_dir(), "root dir itself must survive");
8540        assert_eq!(
8541            fs::read_dir(&root).expect("read root").count(),
8542            0,
8543            "root must be empty after clean"
8544        );
8545        let _ = fs::remove_dir_all(&root);
8546    }
8547
8548    #[cfg(windows)]
8549    #[test]
8550    fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
8551        let bogus = std::env::temp_dir().join(format!(
8552            "pinget-clean-test-bogus-{}",
8553            Utc::now().timestamp_nanos_opt().unwrap_or_default()
8554        ));
8555        assert!(!bogus.exists());
8556        clean_directory_contents(&bogus).expect("clean nonexistent dir");
8557        // Should not have created the dir.
8558        assert!(!bogus.exists());
8559    }
8560
8561    #[cfg(windows)]
8562    #[test]
8563    fn path_prefix_check_handles_mixed_separators() {
8564        // NestedInstallerFiles RelativeFilePath uses '/' in manifests, so the
8565        // path we joined can end up mixed-separator on Windows.
8566        assert!(path_starts_with_case_insensitive(
8567            Path::new(r"C:\Foo\sub/bin.exe"),
8568            Path::new(r"C:\Foo"),
8569        ));
8570    }
8571
8572    #[test]
8573    fn repository_options_capture_custom_host_settings() {
8574        let options =
8575            RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test")).with_user_agent("pinget-rs-tests/1.0");
8576
8577        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
8578        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
8579    }
8580
8581    #[test]
8582    fn dependency_install_requests_are_silent_and_not_forced() {
8583        let mut parent = InstallRequest::new(PackageQuery {
8584            source: Some("winget".to_owned()),
8585            ..PackageQuery::default()
8586        });
8587        parent.mode = InstallerMode::SilentWithProgress;
8588        parent.force = true;
8589        parent.accept_package_agreements = true;
8590        parent.ignore_security_hash = true;
8591        parent.dependency_source = Some("dependencies".to_owned());
8592
8593        let dependency =
8594            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
8595
8596        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
8597        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
8598        assert_eq!(dependency.mode, InstallerMode::Silent);
8599        assert!(!dependency.force);
8600        assert!(dependency.no_upgrade);
8601        assert!(dependency.accept_package_agreements);
8602        assert!(dependency.ignore_security_hash);
8603    }
8604
8605    #[test]
8606    fn storage_paths_use_configured_app_root() {
8607        let app_root = PathBuf::from(r"C:\temp\pinget-test");
8608        let source = SourceRecord {
8609            name: "winget/test".to_owned(),
8610            kind: SourceKind::PreIndexed,
8611            arg: "https://example.com/cache".to_owned(),
8612            identifier: "Test.Source".to_owned(),
8613            trust_level: "None".to_owned(),
8614            explicit: false,
8615            priority: 0,
8616            last_update: None,
8617            source_version: None,
8618        };
8619
8620        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
8621        assert_eq!(
8622            source_state_dir(&app_root, &source),
8623            app_root.join("sources").join("winget_test")
8624        );
8625        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
8626    }
8627
8628    #[cfg(windows)]
8629    #[test]
8630    fn packaged_layout_paths_resolve_from_packaged_app_root() {
8631        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
8632        let app_root = local_app_data
8633            .join("Packages")
8634            .join(PACKAGED_FAMILY_NAME)
8635            .join("LocalState");
8636        assert!(uses_packaged_layout(&app_root));
8637        assert!(
8638            user_settings_path(&app_root)
8639                .display()
8640                .to_string()
8641                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
8642        );
8643        assert!(
8644            packaged_file_cache_root(&app_root)
8645                .display()
8646                .to_string()
8647                .ends_with(&format!(
8648                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
8649                    PACKAGED_FAMILY_NAME
8650                ))
8651        );
8652    }
8653
8654    #[cfg(windows)]
8655    #[test]
8656    fn default_app_root_outside_package_avoids_packaged_layout() {
8657        // Tests run without AppX package identity, so the default app root must not
8658        // resolve to the WinGet packaged LocalState — that location requires a
8659        // brokered/elevated writer for its secure-settings stream.
8660        let prior = std::env::var_os("PINGET_APPROOT");
8661        // SAFETY: tests in this module are not run concurrently with other env mutators.
8662        unsafe { std::env::remove_var("PINGET_APPROOT") };
8663
8664        let result = default_app_root();
8665
8666        if let Some(prior) = prior {
8667            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
8668            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
8669        }
8670
8671        let app_root = result.expect("default app root");
8672        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
8673        assert!(
8674            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
8675            "unexpected fallback app root: {app_root:?}"
8676        );
8677    }
8678
8679    #[test]
8680    fn packaged_source_stream_overlays_defaults_and_metadata() {
8681        let store = parse_packaged_source_store(
8682            Some(
8683                "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",
8684            ),
8685            Some(
8686                "Sources:\n  - Name: corp\n    LastUpdate: 1700000000\n    SourceVersion: 1.2.3\n",
8687            ),
8688        )
8689        .expect("packaged store");
8690
8691        assert!(!store.sources.iter().any(|source| source.name == "winget"));
8692        let corp = store
8693            .sources
8694            .iter()
8695            .find(|source| source.name == "corp")
8696            .expect("corp source");
8697        assert_eq!(corp.kind, SourceKind::Rest);
8698        assert_eq!(corp.identifier, "Contoso.Rest");
8699        assert_eq!(corp.trust_level, "Trusted");
8700        assert!(corp.explicit);
8701        assert_eq!(corp.priority, 7);
8702        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
8703        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
8704    }
8705
8706    #[test]
8707    fn system_winget_export_parses_json_lines() {
8708        let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
8709{"Arg":"https://cdn.contoso.test/cache","Data":"Contoso.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Contoso.Source_8wekyb3d8bbwe","Name":"contoso-cache","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
8710"#;
8711
8712        let sources = parse_system_winget_source_export(output).expect("source export");
8713
8714        assert_eq!(
8715            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
8716            vec!["contoso", "contoso-cache"]
8717        );
8718        assert_eq!(sources[0].kind, SourceKind::Rest);
8719        assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
8720        assert_eq!(sources[0].identifier, "api.contoso.test");
8721        assert_eq!(sources[0].trust_level, "Trusted");
8722        assert!(!sources[0].explicit);
8723        assert_eq!(sources[1].kind, SourceKind::PreIndexed);
8724        assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
8725        assert!(sources[1].explicit);
8726    }
8727
8728    #[test]
8729    fn system_winget_export_parses_wrapped_sources() {
8730        let output = r#"{
8731  "Sources": [
8732    {
8733      "Name": "corp",
8734      "Type": "Microsoft.Rest",
8735      "Arg": "https://packages.contoso.test/api",
8736      "Identifier": "Contoso.Rest",
8737      "TrustLevel": "Trusted",
8738      "Explicit": true,
8739      "Priority": 9
8740    }
8741  ]
8742}"#;
8743
8744        let sources = parse_system_winget_source_export(output).expect("source export");
8745        let source = sources.first().expect("source");
8746        assert_eq!(sources.len(), 1);
8747        assert_eq!(source.name, "corp");
8748        assert_eq!(source.kind, SourceKind::Rest);
8749        assert_eq!(source.identifier, "Contoso.Rest");
8750        assert_eq!(source.trust_level, "Trusted");
8751        assert!(source.explicit);
8752        assert_eq!(source.priority, 9);
8753    }
8754
8755    #[test]
8756    fn system_winget_source_store_detects_secure_settings_stub() {
8757        assert!(is_secure_settings_stub(Some(
8758            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
8759        )));
8760        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
8761    }
8762
8763    #[test]
8764    fn system_winget_source_store_builds_add_arguments() {
8765        let args = build_winget_source_add_arguments(
8766            "corp",
8767            "https://packages.contoso.test/api",
8768            SourceKind::Rest,
8769            "Trusted",
8770            true,
8771        );
8772
8773        assert_eq!(
8774            args,
8775            vec![
8776                "source",
8777                "add",
8778                "--name",
8779                "corp",
8780                "--arg",
8781                "https://packages.contoso.test/api",
8782                "--type",
8783                "Microsoft.Rest",
8784                "--disable-interactivity",
8785                "--trust-level",
8786                "trusted",
8787                "--explicit",
8788            ]
8789        );
8790    }
8791
8792    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
8793        Ok(WingetSourceCommandResult {
8794            exit_code: 0,
8795            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
8796"#
8797            .to_owned(),
8798            stderr: String::new(),
8799        })
8800    }
8801
8802    #[test]
8803    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
8804        let original_runner = {
8805            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
8806            let original = *runner;
8807            *runner = fake_system_winget_source_export;
8808            original
8809        };
8810
8811        let result = load_packaged_store_from_streams(
8812            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
8813            None,
8814        );
8815
8816        {
8817            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
8818            *runner = original_runner;
8819        }
8820
8821        let store = result.expect("packaged store").expect("packaged store");
8822        let source = store.sources.first().expect("source");
8823        assert_eq!(store.sources.len(), 1);
8824        assert_eq!(source.name, "contoso");
8825        assert_eq!(source.kind, SourceKind::Rest);
8826    }
8827
8828    #[test]
8829    fn source_metadata_and_named_reset_round_trip() {
8830        let app_root = temp_app_root("source-reset");
8831        let result = (|| -> Result<()> {
8832            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8833            repository.add_source_with_metadata(
8834                "test",
8835                "https://example.com/test",
8836                SourceKind::Rest,
8837                Some("trusted"),
8838                true,
8839                4,
8840            )?;
8841
8842            repository.edit_source("test", Some(false), None)?;
8843            let edited = repository
8844                .list_sources()
8845                .into_iter()
8846                .find(|source| source.name == "test")
8847                .expect("source");
8848            assert_eq!(edited.trust_level, "Trusted");
8849            assert!(!edited.explicit);
8850            assert_eq!(edited.priority, 4);
8851
8852            repository.reset_source("test")?;
8853            let reset = repository
8854                .list_sources()
8855                .into_iter()
8856                .find(|source| source.name == "test")
8857                .expect("source");
8858            assert_eq!(reset.trust_level, "Trusted");
8859            assert!(!reset.explicit);
8860            assert_eq!(reset.priority, 4);
8861            assert!(reset.last_update.is_none());
8862            assert!(reset.source_version.is_none());
8863            Ok(())
8864        })();
8865
8866        let _ = fs::remove_dir_all(&app_root);
8867        result.expect("source metadata");
8868    }
8869
8870    #[test]
8871    fn explicit_sources_are_skipped_by_default_search_resolution() {
8872        let app_root = temp_app_root("source-resolution");
8873        let result = (|| -> Result<()> {
8874            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8875            repository.add_source_with_metadata(
8876                "explicit-test",
8877                "https://example.com/explicit",
8878                SourceKind::Rest,
8879                Some("trusted"),
8880                true,
8881                10,
8882            )?;
8883
8884            let indexes = repository.resolve_source_indexes(None)?;
8885            let names: Vec<_> = indexes
8886                .into_iter()
8887                .map(|index| repository.list_sources()[index].name.clone())
8888                .collect();
8889            assert!(!names.iter().any(|name| name == "explicit-test"));
8890            Ok(())
8891        })();
8892
8893        let _ = fs::remove_dir_all(&app_root);
8894        result.expect("source resolution");
8895    }
8896
8897    #[test]
8898    fn user_and_admin_settings_round_trip() {
8899        let app_root = temp_app_root("settings");
8900        let result = (|| -> Result<()> {
8901            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8902            repository.set_user_settings(
8903                &serde_json::json!({
8904                    "visual": {
8905                        "progressBar": "retro"
8906                    }
8907                }),
8908                false,
8909            )?;
8910            repository.set_user_settings(
8911                &serde_json::json!({
8912                    "experimentalFeatures": {
8913                        "directMSI": true
8914                    }
8915                }),
8916                true,
8917            )?;
8918
8919            let user_settings = repository.get_user_settings()?;
8920            assert_eq!(
8921                user_settings["visual"]["progressBar"],
8922                serde_json::Value::String("retro".to_owned())
8923            );
8924            assert_eq!(
8925                user_settings["experimentalFeatures"]["directMSI"],
8926                serde_json::Value::Bool(true)
8927            );
8928            assert!(repository.test_user_settings(
8929                &serde_json::json!({
8930                    "experimentalFeatures": {
8931                        "directMSI": true
8932                    }
8933                }),
8934                true
8935            )?);
8936
8937            repository.set_admin_setting("LocalManifestFiles", true)?;
8938            repository.set_admin_setting("InstallerHashOverride", true)?;
8939            let admin_settings = repository.get_admin_settings()?;
8940            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
8941            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
8942
8943            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
8944            let reset_one = repository.get_admin_settings()?;
8945            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
8946
8947            repository.reset_admin_setting(None, true)?;
8948            let reset_all = repository.get_admin_settings()?;
8949            for name in Repository::supported_admin_settings() {
8950                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
8951            }
8952            Ok(())
8953        })();
8954
8955        let _ = fs::remove_dir_all(&app_root);
8956        result.expect("settings");
8957    }
8958
8959    #[test]
8960    fn show_result_structured_document_is_manifest_oriented() {
8961        let result = ShowResult {
8962            package: SearchMatch {
8963                source_name: "winget".to_owned(),
8964                source_kind: SourceKind::PreIndexed,
8965                id: "Test.Package".to_owned(),
8966                name: "Test Package".to_owned(),
8967                moniker: Some("testpkg".to_owned()),
8968                version: Some("1.2.3".to_owned()),
8969                channel: Some("stable".to_owned()),
8970                match_criteria: Some("Id".to_owned()),
8971            },
8972            manifest: Manifest {
8973                id: "Test.Package".to_owned(),
8974                name: "Test Package".to_owned(),
8975                version: "1.2.3".to_owned(),
8976                channel: "stable".to_owned(),
8977                publisher: Some("Contoso".to_owned()),
8978                description: Some("Structured output".to_owned()),
8979                moniker: Some("testpkg".to_owned()),
8980                package_url: None,
8981                publisher_url: None,
8982                publisher_support_url: None,
8983                license: None,
8984                license_url: None,
8985                privacy_url: None,
8986                author: None,
8987                copyright: None,
8988                copyright_url: None,
8989                release_notes: None,
8990                release_notes_url: None,
8991                tags: vec!["utility".to_owned()],
8992                agreements: Vec::new(),
8993                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
8994                documentation: vec![Documentation {
8995                    label: Some("Docs".to_owned()),
8996                    url: "https://example.test/docs".to_owned(),
8997                }],
8998                installers: vec![Installer {
8999                    architecture: Some("x64".to_owned()),
9000                    installer_type: Some("msix".to_owned()),
9001                    url: Some("https://example.test/Test.Package.msix".to_owned()),
9002                    sha256: Some("ABC123".to_owned()),
9003                    product_code: None,
9004                    locale: Some("en-US".to_owned()),
9005                    scope: Some("machine".to_owned()),
9006                    release_date: None,
9007                    package_family_name: None,
9008                    upgrade_code: None,
9009                    platforms: Vec::new(),
9010                    minimum_os_version: None,
9011                    switches: InstallerSwitches {
9012                        silent: Some("/quiet".to_owned()),
9013                        ..InstallerSwitches::default()
9014                    },
9015                    commands: vec!["testpkg".to_owned()],
9016                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9017                    require_explicit_upgrade: false,
9018                    nested_installer_type: None,
9019                    nested_installer_files: Vec::new(),
9020                }],
9021                require_explicit_upgrade: false,
9022            },
9023            selected_installer: Some(Installer {
9024                architecture: Some("x64".to_owned()),
9025                installer_type: Some("msix".to_owned()),
9026                url: Some("https://example.test/Test.Package.msix".to_owned()),
9027                sha256: Some("ABC123".to_owned()),
9028                product_code: None,
9029                locale: Some("en-US".to_owned()),
9030                scope: Some("machine".to_owned()),
9031                release_date: None,
9032                package_family_name: None,
9033                upgrade_code: None,
9034                platforms: Vec::new(),
9035                minimum_os_version: None,
9036                switches: InstallerSwitches {
9037                    silent: Some("/quiet".to_owned()),
9038                    ..InstallerSwitches::default()
9039                },
9040                commands: vec!["testpkg".to_owned()],
9041                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9042                require_explicit_upgrade: false,
9043                nested_installer_type: None,
9044                nested_installer_files: Vec::new(),
9045            }),
9046            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
9047            warnings: vec!["cache warmed".to_owned()],
9048            manifest_documents: JsonValue::Array(vec![
9049                serde_json::json!({
9050                    "PackageIdentifier": "Test.Package",
9051                    "PackageVersion": "1.2.3",
9052                    "DefaultLocale": "en-US",
9053                    "ManifestType": "version",
9054                    "ManifestVersion": "1.10.0"
9055                }),
9056                serde_json::json!({
9057                    "PackageIdentifier": "Test.Package",
9058                    "PackageVersion": "1.2.3",
9059                    "PackageLocale": "en-US",
9060                    "PackageName": "Test Package",
9061                    "Publisher": "Example",
9062                    "License": "MIT",
9063                    "ShortDescription": "Structured output",
9064                    "ManifestType": "defaultLocale",
9065                    "ManifestVersion": "1.10.0"
9066                }),
9067                serde_json::json!({
9068                    "PackageIdentifier": "Test.Package",
9069                    "PackageVersion": "1.2.3",
9070                    "ManifestType": "installer",
9071                    "ManifestVersion": "1.10.0",
9072                    "Installers": [
9073                        {
9074                            "Architecture": "x64",
9075                            "InstallerType": "msix",
9076                            "InstallerUrl": "https://example.test/Test.Package.msix",
9077                            "InstallerSha256": "ABC123",
9078                            "Commands": ["testpkg"],
9079                            "InstallerSwitches": { "Silent": "/quiet" },
9080                            "Dependencies": {
9081                                "PackageDependencies": [
9082                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
9083                                ]
9084                            }
9085                        }
9086                    ]
9087                }),
9088            ]),
9089        };
9090
9091        let document = result.structured_document();
9092
9093        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
9094        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
9095        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
9096        assert_eq!(
9097            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
9098            Some("Microsoft.VCRedist.2015+.x64")
9099        );
9100        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
9101        assert_eq!(
9102            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
9103            Some("/quiet")
9104        );
9105    }
9106
9107    #[test]
9108    fn manifest_parses_require_explicit_upgrade_at_top_level() {
9109        // Top-level RequireExplicitUpgrade flag should propagate to
9110        // Manifest.require_explicit_upgrade. winget catalogs typically
9111        // place the flag here for browser packages and similar
9112        // self-updating apps that opt out of bulk `upgrade`.
9113        let yaml = r#"
9114PackageIdentifier: Test.Package
9115PackageVersion: 1.2.3
9116DefaultLocale: en-US
9117ManifestType: singleton
9118ManifestVersion: 1.10.0
9119PackageLocale: en-US
9120PackageName: Test Package
9121Publisher: Example
9122License: MIT
9123ShortDescription: explicit-upgrade fixture
9124RequireExplicitUpgrade: true
9125Installers:
9126  - Architecture: x64
9127    InstallerType: exe
9128    InstallerUrl: https://example.test/Test.Package.exe
9129    InstallerSha256: ABC123
9130"#;
9131        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9132        assert!(manifest.require_explicit_upgrade);
9133    }
9134
9135    #[test]
9136    fn manifest_parses_require_explicit_upgrade_on_installer() {
9137        // Per-installer flag — only one of several installers declares it,
9138        // but the Manifest aggregate must still be `true` because the user
9139        // could pick that installer when upgrading.
9140        let yaml = r#"
9141PackageIdentifier: Test.Package
9142PackageVersion: 1.2.3
9143DefaultLocale: en-US
9144ManifestType: singleton
9145ManifestVersion: 1.10.0
9146PackageLocale: en-US
9147PackageName: Test Package
9148Publisher: Example
9149License: MIT
9150ShortDescription: explicit-upgrade fixture
9151Installers:
9152  - Architecture: x64
9153    InstallerType: exe
9154    InstallerUrl: https://example.test/Test.Package.x64.exe
9155    InstallerSha256: ABC123
9156  - Architecture: arm64
9157    InstallerType: exe
9158    InstallerUrl: https://example.test/Test.Package.arm64.exe
9159    InstallerSha256: DEF456
9160    RequireExplicitUpgrade: true
9161"#;
9162        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9163        assert!(manifest.require_explicit_upgrade);
9164        assert!(!manifest.installers[0].require_explicit_upgrade);
9165        assert!(manifest.installers[1].require_explicit_upgrade);
9166    }
9167
9168    #[test]
9169    fn manifest_without_require_explicit_upgrade_defaults_to_false() {
9170        let yaml = r#"
9171PackageIdentifier: Test.Package
9172PackageVersion: 1.2.3
9173DefaultLocale: en-US
9174ManifestType: singleton
9175ManifestVersion: 1.10.0
9176PackageLocale: en-US
9177PackageName: Test Package
9178Publisher: Example
9179License: MIT
9180ShortDescription: baseline fixture
9181Installers:
9182  - Architecture: x64
9183    InstallerType: exe
9184    InstallerUrl: https://example.test/Test.Package.exe
9185    InstallerSha256: ABC123
9186"#;
9187        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9188        assert!(!manifest.require_explicit_upgrade);
9189    }
9190
9191    #[test]
9192    fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
9193        // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade`
9194        // (Edge, Steam, Discord). pinget must do the same — the filter is
9195        // the only gate that enforces it.
9196        let mut pkg = InstalledPackage {
9197            name: "Edge".to_owned(),
9198            local_id: r"ARP\Machine\X64\Edge".to_owned(),
9199            installed_version: "100.0".to_owned(),
9200            publisher: None,
9201            scope: Some("Machine".to_owned()),
9202            installer_category: Some("exe".to_owned()),
9203            install_location: None,
9204            package_family_names: Vec::new(),
9205            product_codes: Vec::new(),
9206            upgrade_codes: Vec::new(),
9207            correlated: Some(SearchMatch {
9208                source_name: "winget".to_owned(),
9209                source_kind: SourceKind::PreIndexed,
9210                id: "Microsoft.Edge".to_owned(),
9211                name: "Microsoft Edge".to_owned(),
9212                moniker: None,
9213                version: Some("110.0".to_owned()),
9214                channel: None,
9215                match_criteria: None,
9216            }),
9217            installed_version_canonical: false,
9218            correlated_requires_explicit_upgrade: true,
9219            correlated_lacks_compatible_installer: false,
9220        };
9221        let bulk_query = ListQuery {
9222            upgrade_only: true,
9223            ..ListQuery::default()
9224        };
9225        assert!(
9226            !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
9227            "RequireExplicitUpgrade row must be hidden from bulk upgrade"
9228        );
9229
9230        // When the user explicitly targets it by id, winget shows it —
9231        // pinget must do the same so `pinget upgrade Microsoft.Edge` works.
9232        let filtered_query = ListQuery {
9233            upgrade_only: true,
9234            id: Some("Microsoft.Edge".to_owned()),
9235            ..ListQuery::default()
9236        };
9237        assert!(
9238            installed_package_matches_upgrade_filter(&pkg, &filtered_query),
9239            "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
9240        );
9241
9242        // Without the flag, the row appears in bulk upgrade as usual.
9243        pkg.correlated_requires_explicit_upgrade = false;
9244        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9245    }
9246
9247    #[test]
9248    fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
9249        let mut pkg = InstalledPackage {
9250            name: "Foo".to_owned(),
9251            local_id: r"ARP\Machine\X64\Foo".to_owned(),
9252            installed_version: "1.0".to_owned(),
9253            publisher: None,
9254            scope: Some("Machine".to_owned()),
9255            installer_category: Some("exe".to_owned()),
9256            install_location: None,
9257            package_family_names: Vec::new(),
9258            product_codes: Vec::new(),
9259            upgrade_codes: Vec::new(),
9260            correlated: Some(SearchMatch {
9261                source_name: "winget".to_owned(),
9262                source_kind: SourceKind::PreIndexed,
9263                id: "Test.Foo".to_owned(),
9264                name: "Foo".to_owned(),
9265                moniker: None,
9266                version: Some("2.0".to_owned()),
9267                channel: None,
9268                match_criteria: None,
9269            }),
9270            installed_version_canonical: false,
9271            correlated_requires_explicit_upgrade: false,
9272            correlated_lacks_compatible_installer: true,
9273        };
9274        let bulk_query = ListQuery {
9275            upgrade_only: true,
9276            ..ListQuery::default()
9277        };
9278        assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9279
9280        // Explicit `--id` still surfaces it.
9281        let filtered_query = ListQuery {
9282            upgrade_only: true,
9283            id: Some("Test.Foo".to_owned()),
9284            ..ListQuery::default()
9285        };
9286        assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
9287
9288        // Cleared flag → visible in bulk.
9289        pkg.correlated_lacks_compatible_installer = false;
9290        assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9291    }
9292
9293    fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
9294        let installers = arches
9295            .iter()
9296            .map(|a| Installer {
9297                architecture: Some((*a).to_owned()),
9298                installer_type: None,
9299                url: None,
9300                sha256: None,
9301                product_code: None,
9302                locale: None,
9303                scope: None,
9304                release_date: None,
9305                package_family_name: None,
9306                upgrade_code: None,
9307                platforms: Vec::new(),
9308                minimum_os_version: None,
9309                switches: InstallerSwitches::default(),
9310                commands: Vec::new(),
9311                package_dependencies: Vec::new(),
9312                require_explicit_upgrade: false,
9313                nested_installer_type: None,
9314                nested_installer_files: Vec::new(),
9315            })
9316            .collect();
9317        Manifest {
9318            id: "Test".to_owned(),
9319            name: "Test".to_owned(),
9320            version: "1.0".to_owned(),
9321            channel: String::new(),
9322            publisher: None,
9323            description: None,
9324            moniker: None,
9325            package_url: None,
9326            publisher_url: None,
9327            publisher_support_url: None,
9328            license: None,
9329            license_url: None,
9330            privacy_url: None,
9331            author: None,
9332            copyright: None,
9333            copyright_url: None,
9334            release_notes: None,
9335            release_notes_url: None,
9336            tags: Vec::new(),
9337            agreements: Vec::new(),
9338            package_dependencies: Vec::new(),
9339            documentation: Vec::new(),
9340            installers,
9341            require_explicit_upgrade: false,
9342        }
9343    }
9344
9345    #[test]
9346    fn manifest_compatible_installer_neutral_always_matches() {
9347        // `neutral` is in every host's allowed list.
9348        assert!(manifest_has_compatible_installer(
9349            &synthetic_manifest_with_installer_arches(&["neutral"])
9350        ));
9351    }
9352
9353    #[test]
9354    fn manifest_compatible_installer_empty_installers_pass_through() {
9355        // Manifest with no installers (e.g. a docs-only entry): nothing to
9356        // filter against, don't hide the row.
9357        assert!(manifest_has_compatible_installer(
9358            &synthetic_manifest_with_installer_arches(&[])
9359        ));
9360    }
9361
9362    #[test]
9363    fn manifest_compatible_installer_rejects_alien_arch_only() {
9364        // `ppc` is in no host's allowed list — manifest has nothing runnable.
9365        assert!(!manifest_has_compatible_installer(
9366            &synthetic_manifest_with_installer_arches(&["ppc"])
9367        ));
9368    }
9369
9370    #[test]
9371    fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
9372        // Mixed list where at least `neutral` is in every host's preference.
9373        assert!(manifest_has_compatible_installer(
9374            &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
9375        ));
9376    }
9377
9378    #[test]
9379    fn lookup_unique_normalized_identity_returns_unique_match() {
9380        // Single (norm_name, norm_publisher) intersection — happy path.
9381        let connection = Connection::open_in_memory().expect("open in-memory db");
9382        connection
9383            .execute_batch(
9384                "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
9385                 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
9386                 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
9387                 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
9388            )
9389            .expect("seed schema");
9390
9391        let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
9392            .expect("query")
9393            .expect("unique match");
9394        assert_eq!(rowid, 100);
9395    }
9396
9397    #[test]
9398    fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
9399        // Two distinct packages share the same (norm_name, norm_publisher)
9400        // — the Git case where both `Git.Git` and a hypothetical sibling
9401        // normalize identically. winget refuses to correlate when it can't
9402        // disambiguate; pinget must do the same.
9403        let connection = Connection::open_in_memory().expect("open in-memory db");
9404        connection
9405            .execute_batch(
9406                "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
9407                 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
9408                 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
9409                 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
9410            )
9411            .expect("seed schema");
9412
9413        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
9414        assert!(rowid.is_none(), "ambiguous match must not correlate");
9415    }
9416
9417    #[test]
9418    fn lookup_unique_normalized_identity_requires_publisher_intersect() {
9419        // norm_name has multiple matches but only one shares its
9420        // norm_publisher with the installed package. Winget's intersect
9421        // logic still picks the right one — verify.
9422        let connection = Connection::open_in_memory().expect("open in-memory db");
9423        connection
9424            .execute_batch(
9425                "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
9426                 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
9427                 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
9428                 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
9429            )
9430            .expect("seed schema");
9431
9432        let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
9433        assert_eq!(rowid, Some(100));
9434    }
9435
9436    #[test]
9437    fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
9438        // Name matches but no publisher row for that package id —
9439        // intersection is empty so we don't correlate.
9440        let connection = Connection::open_in_memory().expect("open in-memory db");
9441        connection
9442            .execute_batch(
9443                "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
9444                 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
9445                 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
9446                 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
9447            )
9448            .expect("seed schema");
9449
9450        let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
9451        assert!(
9452            rowid.is_none(),
9453            "name matches package 100 but publisher matches 200 — no intersection"
9454        );
9455    }
9456
9457    #[test]
9458    fn parse_yaml_manifest_bundle_returns_singleton_document() {
9459        let yaml = r#"
9460PackageIdentifier: Test.Package
9461PackageVersion: 1.2.3
9462DefaultLocale: en-US
9463ManifestType: version
9464ManifestVersion: 1.10.0
9465---
9466PackageIdentifier: Test.Package
9467PackageVersion: 1.2.3
9468PackageLocale: en-US
9469PackageName: Test Package
9470Publisher: Example
9471License: MIT
9472ShortDescription: Structured output
9473ManifestType: defaultLocale
9474ManifestVersion: 1.10.0
9475---
9476PackageIdentifier: Test.Package
9477PackageVersion: 1.2.3
9478ManifestType: installer
9479ManifestVersion: 1.10.0
9480Installers:
9481  - Architecture: x64
9482    InstallerType: exe
9483    InstallerUrl: https://example.test/Test.Package.exe
9484    InstallerSha256: ABC123
9485"#;
9486
9487        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
9488
9489        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
9490        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
9491        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
9492    }
9493
9494    #[test]
9495    fn collapse_structured_documents_returns_plural_show_documents() {
9496        let documents = collapse_structured_documents(&[
9497            JsonValue::Array(vec![
9498                serde_json::json!({
9499                    "PackageIdentifier": "Test.Package.One",
9500                    "PackageVersion": "1.0.0",
9501                    "DefaultLocale": "en-US",
9502                    "ManifestType": "version",
9503                    "ManifestVersion": "1.10.0"
9504                }),
9505                serde_json::json!({
9506                    "PackageIdentifier": "Test.Package.One",
9507                    "PackageVersion": "1.0.0",
9508                    "PackageLocale": "en-US",
9509                    "PackageName": "Test Package One",
9510                    "ManifestType": "defaultLocale",
9511                    "ManifestVersion": "1.10.0"
9512                }),
9513                serde_json::json!({
9514                    "PackageIdentifier": "Test.Package.One",
9515                    "PackageVersion": "1.0.0",
9516                    "ManifestType": "installer",
9517                    "ManifestVersion": "1.10.0",
9518                    "Installers": [
9519                        {
9520                            "Architecture": "x64",
9521                            "InstallerType": "exe",
9522                            "InstallerUrl": "https://example.test/one.exe",
9523                            "InstallerSha256": "ABC123"
9524                        }
9525                    ]
9526                }),
9527            ]),
9528            serde_json::json!({
9529                "PackageIdentifier": "Test.Package.Two",
9530                "PackageVersion": "2.0.0",
9531                "PackageLocale": "en-US",
9532                "PackageName": "Test Package Two",
9533                "ManifestType": "singleton",
9534                "ManifestVersion": "1.12.0"
9535            }),
9536        ]);
9537
9538        assert_eq!(documents.len(), 2);
9539        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
9540        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
9541        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
9542        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
9543        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
9544    }
9545
9546    #[test]
9547    fn unsupported_action_result_marks_noop_and_warning() {
9548        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
9549
9550        assert!(result.success);
9551        assert!(result.no_op);
9552        assert_eq!(result.exit_code, 0);
9553        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
9554    }
9555
9556    #[test]
9557    fn create_install_no_op_result_honors_no_upgrade() {
9558        let mut request = InstallRequest::new(PackageQuery {
9559            id: Some("Contoso.App".to_owned()),
9560            ..PackageQuery::default()
9561        });
9562        request.no_upgrade = true;
9563
9564        let manifest = Manifest {
9565            id: "Contoso.App".to_owned(),
9566            name: "Contoso App".to_owned(),
9567            version: "2.0.0".to_owned(),
9568            channel: String::new(),
9569            publisher: None,
9570            description: None,
9571            moniker: None,
9572            package_url: None,
9573            publisher_url: None,
9574            publisher_support_url: None,
9575            license: None,
9576            license_url: None,
9577            privacy_url: None,
9578            author: None,
9579            copyright: None,
9580            copyright_url: None,
9581            release_notes: None,
9582            release_notes_url: None,
9583            tags: Vec::new(),
9584            agreements: Vec::new(),
9585            package_dependencies: Vec::new(),
9586            documentation: Vec::new(),
9587            installers: Vec::new(),
9588            require_explicit_upgrade: false,
9589        };
9590        let existing = ListMatch {
9591            name: "Contoso App".to_owned(),
9592            id: "Contoso.App".to_owned(),
9593            local_id: "Contoso.App".to_owned(),
9594            installed_version: "1.0.0".to_owned(),
9595            available_version: Some("2.0.0".to_owned()),
9596            source_name: Some("winget".to_owned()),
9597            publisher: None,
9598            scope: None,
9599            installer_category: None,
9600            install_location: None,
9601            package_family_names: Vec::new(),
9602            product_codes: Vec::new(),
9603            upgrade_codes: Vec::new(),
9604        };
9605
9606        let result =
9607            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
9608
9609        assert!(result.success);
9610        assert!(result.no_op);
9611        assert_eq!(result.version, "1.0.0");
9612        assert_eq!(
9613            result.warnings,
9614            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
9615        );
9616    }
9617
9618    #[test]
9619    fn create_install_no_op_result_skips_reinstall_when_current() {
9620        let request = InstallRequest::new(PackageQuery {
9621            id: Some("Contoso.App".to_owned()),
9622            ..PackageQuery::default()
9623        });
9624
9625        let manifest = Manifest {
9626            id: "Contoso.App".to_owned(),
9627            name: "Contoso App".to_owned(),
9628            version: "2.0.0".to_owned(),
9629            channel: String::new(),
9630            publisher: None,
9631            description: None,
9632            moniker: None,
9633            package_url: None,
9634            publisher_url: None,
9635            publisher_support_url: None,
9636            license: None,
9637            license_url: None,
9638            privacy_url: None,
9639            author: None,
9640            copyright: None,
9641            copyright_url: None,
9642            release_notes: None,
9643            release_notes_url: None,
9644            tags: Vec::new(),
9645            agreements: Vec::new(),
9646            package_dependencies: Vec::new(),
9647            documentation: Vec::new(),
9648            installers: Vec::new(),
9649            require_explicit_upgrade: false,
9650        };
9651        let existing = ListMatch {
9652            name: "Contoso App".to_owned(),
9653            id: "Contoso.App".to_owned(),
9654            local_id: "Contoso.App".to_owned(),
9655            installed_version: "2.0.0".to_owned(),
9656            available_version: Some("2.0.0".to_owned()),
9657            source_name: Some("winget".to_owned()),
9658            publisher: None,
9659            scope: None,
9660            installer_category: None,
9661            install_location: None,
9662            package_family_names: Vec::new(),
9663            product_codes: Vec::new(),
9664            upgrade_codes: Vec::new(),
9665        };
9666
9667        let result =
9668            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
9669
9670        assert!(result.success);
9671        assert!(result.no_op);
9672        assert_eq!(result.version, "2.0.0");
9673        assert_eq!(
9674            result.warnings,
9675            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
9676        );
9677    }
9678
9679    #[test]
9680    fn collapse_structured_document_projects_merged_manifest() {
9681        let document = collapse_structured_document(&serde_json::json!({
9682            "PackageIdentifier": "Test.Package",
9683            "PackageVersion": "1.2.3",
9684            "PackageLocale": "en-US",
9685            "PackageName": "Test Package",
9686            "Publisher": "Example",
9687            "InstallerType": "exe",
9688            "Installers": [
9689                {
9690                    "Architecture": "x64",
9691                    "InstallerUrl": "https://example.test/Test.Package.exe",
9692                    "InstallerSha256": "ABC123"
9693                }
9694            ],
9695            "ManifestType": "merged",
9696            "ManifestVersion": "1.10.0"
9697        }));
9698
9699        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
9700        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
9701        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
9702        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
9703        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
9704    }
9705
9706    #[test]
9707    fn parses_fixture_manifest() {
9708        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
9709            .join("tests")
9710            .join("fixtures")
9711            .join("ManifestV1_28-Singleton.yaml");
9712        let bytes = fs::read(root).expect("fixture bytes");
9713        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
9714        assert_eq!(manifest.id, "microsoft.msixsdk");
9715        assert_eq!(manifest.version, "1.7.32");
9716        assert_eq!(manifest.name, "MSIX SDK");
9717        assert!(!manifest.installers.is_empty());
9718    }
9719
9720    #[test]
9721    fn parses_installer_switches_from_yaml_manifest() {
9722        let yaml = r#"
9723PackageIdentifier: Test.Package
9724PackageVersion: 1.2.3
9725PackageName: Test Package
9726InstallerSwitches:
9727  SilentWithProgress: /SILENT
9728Installers:
9729  - Architecture: x64
9730    InstallerType: inno
9731    InstallerUrl: https://example.test/Test.Package.exe
9732    InstallerSha256: ABC123
9733    InstallerSwitches:
9734      Silent: /VERYSILENT
9735      Interactive: /HELP
9736"#;
9737
9738        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
9739        let installer = &manifest.installers[0];
9740
9741        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
9742        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
9743        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
9744    }
9745
9746    #[test]
9747    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
9748        let yaml = r#"
9749PackageIdentifier: Test.Package
9750PackageVersion: 1.2.3
9751PackageName: Test Package
9752Platform:
9753  - Windows.Desktop
9754MinimumOSVersion: 10.0.19041.0
9755Installers:
9756  - Architecture: x64
9757    InstallerType: exe
9758    InstallerUrl: https://example.test/Test.Package.exe
9759    InstallerSha256: ABC123
9760  - Architecture: x64
9761    InstallerType: msix
9762    Platform:
9763      - Windows.Universal
9764    MinimumOSVersion: 10.0.22621.0
9765    InstallerUrl: https://example.test/Test.Package.msix
9766    InstallerSha256: DEF456
9767"#;
9768
9769        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
9770
9771        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
9772        assert_eq!(
9773            manifest.installers[0].minimum_os_version.as_deref(),
9774            Some("10.0.19041.0")
9775        );
9776        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
9777        assert_eq!(
9778            manifest.installers[1].minimum_os_version.as_deref(),
9779            Some("10.0.22621.0")
9780        );
9781    }
9782
9783    #[test]
9784    fn installer_switch_arguments_prefer_manifest_switches() {
9785        let installer = Installer {
9786            architecture: None,
9787            installer_type: Some("inno".to_owned()),
9788            url: None,
9789            sha256: None,
9790            product_code: None,
9791            locale: None,
9792            scope: None,
9793            release_date: None,
9794            package_family_name: None,
9795            upgrade_code: None,
9796            platforms: Vec::new(),
9797            minimum_os_version: None,
9798            switches: InstallerSwitches {
9799                silent: Some("/mysilent".to_owned()),
9800                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
9801                interactive: Some("/myinteractive".to_owned()),
9802                ..InstallerSwitches::default()
9803            },
9804            commands: Vec::new(),
9805            package_dependencies: Vec::new(),
9806            require_explicit_upgrade: false,
9807            nested_installer_type: None,
9808            nested_installer_files: Vec::new(),
9809        };
9810        let manifest = Manifest {
9811            id: "Test.Package".to_owned(),
9812            name: "Test Package".to_owned(),
9813            version: "1.0.0".to_owned(),
9814            channel: String::new(),
9815            publisher: None,
9816            description: None,
9817            moniker: None,
9818            package_url: None,
9819            publisher_url: None,
9820            publisher_support_url: None,
9821            license: None,
9822            license_url: None,
9823            privacy_url: None,
9824            author: None,
9825            copyright: None,
9826            copyright_url: None,
9827            release_notes: None,
9828            release_notes_url: None,
9829            tags: Vec::new(),
9830            agreements: Vec::new(),
9831            package_dependencies: Vec::new(),
9832            documentation: Vec::new(),
9833            installers: Vec::new(),
9834            require_explicit_upgrade: false,
9835        };
9836
9837        let mut silent_request = InstallRequest::new(PackageQuery::default());
9838        silent_request.mode = InstallerMode::Silent;
9839        let mut progress_request = InstallRequest::new(PackageQuery::default());
9840        progress_request.mode = InstallerMode::SilentWithProgress;
9841        let mut interactive_request = InstallRequest::new(PackageQuery::default());
9842        interactive_request.mode = InstallerMode::Interactive;
9843        assert_eq!(
9844            installer_command_arguments(
9845                "inno",
9846                &silent_request,
9847                &manifest,
9848                Path::new("installer.exe"),
9849                &installer
9850            ),
9851            vec!["/mysilent".to_owned()]
9852        );
9853        assert_eq!(
9854            installer_command_arguments(
9855                "inno",
9856                &progress_request,
9857                &manifest,
9858                Path::new("installer.exe"),
9859                &installer
9860            ),
9861            vec!["/mysilentwithprogress".to_owned()]
9862        );
9863        assert_eq!(
9864            installer_command_arguments(
9865                "inno",
9866                &interactive_request,
9867                &manifest,
9868                Path::new("installer.exe"),
9869                &installer
9870            ),
9871            vec!["/myinteractive".to_owned()]
9872        );
9873    }
9874
9875    #[test]
9876    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
9877        let installer = Installer {
9878            architecture: None,
9879            installer_type: Some("inno".to_owned()),
9880            url: None,
9881            sha256: None,
9882            product_code: None,
9883            locale: None,
9884            scope: None,
9885            release_date: None,
9886            package_family_name: None,
9887            upgrade_code: None,
9888            platforms: Vec::new(),
9889            minimum_os_version: None,
9890            switches: InstallerSwitches::default(),
9891            commands: Vec::new(),
9892            package_dependencies: Vec::new(),
9893            require_explicit_upgrade: false,
9894            nested_installer_type: None,
9895            nested_installer_files: Vec::new(),
9896        };
9897        let manifest = Manifest {
9898            id: "Test.Package".to_owned(),
9899            name: "Test Package".to_owned(),
9900            version: "1.0.0".to_owned(),
9901            channel: String::new(),
9902            publisher: None,
9903            description: None,
9904            moniker: None,
9905            package_url: None,
9906            publisher_url: None,
9907            publisher_support_url: None,
9908            license: None,
9909            license_url: None,
9910            privacy_url: None,
9911            author: None,
9912            copyright: None,
9913            copyright_url: None,
9914            release_notes: None,
9915            release_notes_url: None,
9916            tags: Vec::new(),
9917            agreements: Vec::new(),
9918            package_dependencies: Vec::new(),
9919            documentation: Vec::new(),
9920            installers: Vec::new(),
9921            require_explicit_upgrade: false,
9922        };
9923        let mut progress_request = InstallRequest::new(PackageQuery::default());
9924        progress_request.mode = InstallerMode::SilentWithProgress;
9925        let mut silent_request = InstallRequest::new(PackageQuery::default());
9926        silent_request.mode = InstallerMode::Silent;
9927
9928        assert_eq!(
9929            installer_command_arguments(
9930                "inno",
9931                &progress_request,
9932                &manifest,
9933                Path::new("installer.exe"),
9934                &installer
9935            ),
9936            vec![
9937                "/SP-".to_owned(),
9938                "/SILENT".to_owned(),
9939                "/SUPPRESSMSGBOXES".to_owned(),
9940                "/NORESTART".to_owned()
9941            ]
9942        );
9943        assert_eq!(
9944            installer_command_arguments(
9945                "inno",
9946                &silent_request,
9947                &manifest,
9948                Path::new("installer.exe"),
9949                &installer
9950            ),
9951            vec![
9952                "/SP-".to_owned(),
9953                "/VERYSILENT".to_owned(),
9954                "/SUPPRESSMSGBOXES".to_owned(),
9955                "/NORESTART".to_owned()
9956            ]
9957        );
9958    }
9959
9960    #[test]
9961    fn installer_command_arguments_append_manifest_and_cli_switches() {
9962        let installer = Installer {
9963            architecture: None,
9964            installer_type: Some("msi".to_owned()),
9965            url: None,
9966            sha256: None,
9967            product_code: None,
9968            locale: None,
9969            scope: None,
9970            release_date: None,
9971            package_family_name: None,
9972            upgrade_code: None,
9973            platforms: Vec::new(),
9974            minimum_os_version: None,
9975            switches: InstallerSwitches {
9976                custom: Some("ADDLOCAL=Core".to_owned()),
9977                log: Some("/log \"<LOGPATH>\"".to_owned()),
9978                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
9979                ..InstallerSwitches::default()
9980            },
9981            commands: Vec::new(),
9982            package_dependencies: Vec::new(),
9983            require_explicit_upgrade: false,
9984            nested_installer_type: None,
9985            nested_installer_files: Vec::new(),
9986        };
9987        let manifest = Manifest {
9988            id: "ShareX.ShareX".to_owned(),
9989            name: "ShareX".to_owned(),
9990            version: "19.0.2".to_owned(),
9991            channel: String::new(),
9992            publisher: None,
9993            description: None,
9994            moniker: None,
9995            package_url: None,
9996            publisher_url: None,
9997            publisher_support_url: None,
9998            license: None,
9999            license_url: None,
10000            privacy_url: None,
10001            author: None,
10002            copyright: None,
10003            copyright_url: None,
10004            release_notes: None,
10005            release_notes_url: None,
10006            tags: Vec::new(),
10007            agreements: Vec::new(),
10008            package_dependencies: Vec::new(),
10009            documentation: Vec::new(),
10010            installers: Vec::new(),
10011            require_explicit_upgrade: false,
10012        };
10013        let mut request = InstallRequest::new(PackageQuery::default());
10014        request.mode = InstallerMode::Silent;
10015        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
10016        request.custom = Some("REBOOT=ReallySuppress".to_owned());
10017        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
10018
10019        assert_eq!(
10020            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
10021            vec![
10022                "/i".to_owned(),
10023                r"C:\temp\ShareX.msi".to_owned(),
10024                "/quiet".to_owned(),
10025                "/norestart".to_owned(),
10026                "/log".to_owned(),
10027                r"C:\temp\winget.log".to_owned(),
10028                "ADDLOCAL=Core".to_owned(),
10029                "REBOOT=ReallySuppress".to_owned(),
10030                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
10031            ]
10032        );
10033    }
10034
10035    #[test]
10036    fn decompresses_mszyml_payload() {
10037        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
10038        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10039        encoder.write_all(payload.as_bytes()).expect("write payload");
10040        let compressed = encoder.finish().expect("finish payload");
10041        let mut mszip = b"CK".to_vec();
10042        mszip.extend_from_slice(&compressed);
10043
10044        let decompressed = decompress_mszyml(&mszip).expect("decompress");
10045        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10046        assert_eq!(parsed.versions[0].version, "1.2.3");
10047    }
10048
10049    #[test]
10050    fn decompresses_mszyml_payload_with_prefix() {
10051        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
10052        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10053        encoder.write_all(payload.as_bytes()).expect("write payload");
10054        let compressed = encoder.finish().expect("finish payload");
10055        let mut prefixed = vec![0u8; 28];
10056        prefixed.extend_from_slice(b"CK");
10057        prefixed.extend_from_slice(&compressed);
10058
10059        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
10060        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10061        assert_eq!(parsed.versions[0].version, "1.2.3");
10062    }
10063
10064    #[test]
10065    fn compares_versions_naturally() {
10066        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
10067        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
10068        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
10069    }
10070
10071    #[test]
10072    fn installer_hash_verification_fails_on_mismatch_without_override() {
10073        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
10074        assert!(result.is_err());
10075        assert!(
10076            result
10077                .expect_err("hash mismatch")
10078                .to_string()
10079                .contains("Installer hash mismatch")
10080        );
10081    }
10082
10083    #[test]
10084    fn installer_hash_verification_allows_mismatch_with_override() {
10085        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
10086        verify_installer_hash(None, b"pinget", false).expect("missing hash");
10087    }
10088
10089    #[test]
10090    fn search_query_many_includes_tag_and_command_conditions() {
10091        let query = PackageQuery {
10092            query: Some("terminal".to_owned()),
10093            ..PackageQuery::default()
10094        };
10095
10096        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10097
10098        assert!(where_clause.contains("tags2"));
10099        assert!(where_clause.contains("commands2"));
10100        assert_eq!(params.len(), 5);
10101    }
10102
10103    #[test]
10104    fn show_query_single_omits_tag_and_command_conditions() {
10105        let query = PackageQuery {
10106            query: Some("terminal".to_owned()),
10107            ..PackageQuery::default()
10108        };
10109
10110        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
10111
10112        assert!(!where_clause.contains("tags2"));
10113        assert!(!where_clause.contains("commands2"));
10114        assert_eq!(params.len(), 3);
10115    }
10116
10117    #[test]
10118    fn explicit_tag_filter_uses_exact_match() {
10119        let query = PackageQuery {
10120            tag: Some("terminal".to_owned()),
10121            ..PackageQuery::default()
10122        };
10123
10124        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10125
10126        assert!(where_clause.contains("tags2"));
10127        assert_eq!(params, vec!["terminal".to_owned()]);
10128    }
10129
10130    #[test]
10131    fn rest_tag_filter_uses_exact_match() {
10132        let query = PackageQuery {
10133            tag: Some("terminal".to_owned()),
10134            ..PackageQuery::default()
10135        };
10136        let info = RestInformation {
10137            required_package_match_fields: Vec::new(),
10138            unsupported_package_match_fields: Vec::new(),
10139            required_query_parameters: Vec::new(),
10140            ..RestInformation::default()
10141        };
10142
10143        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
10144        let filters = body["Filters"].as_array().expect("filters");
10145
10146        assert_eq!(filters.len(), 1);
10147        assert_eq!(filters[0]["PackageMatchField"], "Tag");
10148        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
10149        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
10150    }
10151
10152    #[test]
10153    fn selects_installer_using_requested_filters() {
10154        let installers = vec![
10155            Installer {
10156                architecture: Some("x86".to_owned()),
10157                installer_type: Some("zip".to_owned()),
10158                url: None,
10159                sha256: None,
10160                product_code: None,
10161                locale: Some("en-US".to_owned()),
10162                scope: Some("user".to_owned()),
10163                release_date: None,
10164                package_family_name: None,
10165                upgrade_code: None,
10166                platforms: Vec::new(),
10167                minimum_os_version: None,
10168                switches: InstallerSwitches::default(),
10169                commands: Vec::new(),
10170                package_dependencies: Vec::new(),
10171                require_explicit_upgrade: false,
10172                nested_installer_type: None,
10173                nested_installer_files: Vec::new(),
10174            },
10175            Installer {
10176                architecture: Some("x64".to_owned()),
10177                installer_type: Some("msix".to_owned()),
10178                url: None,
10179                sha256: None,
10180                product_code: None,
10181                locale: Some("en-US".to_owned()),
10182                scope: Some("user".to_owned()),
10183                release_date: None,
10184                package_family_name: None,
10185                upgrade_code: None,
10186                platforms: Vec::new(),
10187                minimum_os_version: None,
10188                switches: InstallerSwitches::default(),
10189                commands: vec!["demo".to_owned()],
10190                package_dependencies: Vec::new(),
10191                require_explicit_upgrade: false,
10192                nested_installer_type: None,
10193                nested_installer_files: Vec::new(),
10194            },
10195        ];
10196        let query = PackageQuery {
10197            installer_type: Some("msix".to_owned()),
10198            installer_architecture: Some("x64".to_owned()),
10199            install_scope: Some("user".to_owned()),
10200            locale: Some("en-US".to_owned()),
10201            ..PackageQuery::default()
10202        };
10203
10204        let selected = select_installer(&installers, &query).expect("selected installer");
10205        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
10206        assert_eq!(selected.architecture.as_deref(), Some("x64"));
10207    }
10208
10209    #[test]
10210    fn selects_first_installer_when_rank_ties() {
10211        let installers = vec![
10212            Installer {
10213                architecture: Some("x64".to_owned()),
10214                installer_type: Some("exe".to_owned()),
10215                url: None,
10216                sha256: None,
10217                product_code: None,
10218                locale: Some("en-US".to_owned()),
10219                scope: Some("machine".to_owned()),
10220                release_date: None,
10221                package_family_name: None,
10222                upgrade_code: None,
10223                platforms: Vec::new(),
10224                minimum_os_version: None,
10225                switches: InstallerSwitches::default(),
10226                commands: Vec::new(),
10227                package_dependencies: Vec::new(),
10228                require_explicit_upgrade: false,
10229                nested_installer_type: None,
10230                nested_installer_files: Vec::new(),
10231            },
10232            Installer {
10233                architecture: Some("x64".to_owned()),
10234                installer_type: Some("exe".to_owned()),
10235                url: None,
10236                sha256: None,
10237                product_code: None,
10238                locale: Some("en-US".to_owned()),
10239                scope: Some("user".to_owned()),
10240                release_date: None,
10241                package_family_name: None,
10242                upgrade_code: None,
10243                platforms: Vec::new(),
10244                minimum_os_version: None,
10245                switches: InstallerSwitches::default(),
10246                commands: Vec::new(),
10247                package_dependencies: Vec::new(),
10248                require_explicit_upgrade: false,
10249                nested_installer_type: None,
10250                nested_installer_files: Vec::new(),
10251            },
10252        ];
10253
10254        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
10255        assert_eq!(selected.scope.as_deref(), Some("machine"));
10256    }
10257
10258    #[test]
10259    fn selects_installer_using_requested_platform() {
10260        let installers = vec![
10261            Installer {
10262                architecture: Some("x64".to_owned()),
10263                installer_type: Some("exe".to_owned()),
10264                url: None,
10265                sha256: None,
10266                product_code: None,
10267                locale: Some("en-US".to_owned()),
10268                scope: Some("user".to_owned()),
10269                release_date: None,
10270                package_family_name: None,
10271                upgrade_code: None,
10272                platforms: vec!["Windows.Universal".to_owned()],
10273                minimum_os_version: None,
10274                switches: InstallerSwitches::default(),
10275                commands: Vec::new(),
10276                package_dependencies: Vec::new(),
10277                require_explicit_upgrade: false,
10278                nested_installer_type: None,
10279                nested_installer_files: Vec::new(),
10280            },
10281            Installer {
10282                architecture: Some("x64".to_owned()),
10283                installer_type: Some("exe".to_owned()),
10284                url: None,
10285                sha256: None,
10286                product_code: None,
10287                locale: Some("en-US".to_owned()),
10288                scope: Some("user".to_owned()),
10289                release_date: None,
10290                package_family_name: None,
10291                upgrade_code: None,
10292                platforms: vec!["Windows.Desktop".to_owned()],
10293                minimum_os_version: None,
10294                switches: InstallerSwitches::default(),
10295                commands: Vec::new(),
10296                package_dependencies: Vec::new(),
10297                require_explicit_upgrade: false,
10298                nested_installer_type: None,
10299                nested_installer_files: Vec::new(),
10300            },
10301        ];
10302        let query = PackageQuery {
10303            installer_type: Some("exe".to_owned()),
10304            platform: Some("Windows.Desktop".to_owned()),
10305            ..PackageQuery::default()
10306        };
10307
10308        let selected = select_installer(&installers, &query).expect("selected installer");
10309        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
10310    }
10311
10312    #[test]
10313    fn selects_installer_using_requested_os_version() {
10314        let installers = vec![
10315            Installer {
10316                architecture: Some("x64".to_owned()),
10317                installer_type: Some("exe".to_owned()),
10318                url: None,
10319                sha256: None,
10320                product_code: None,
10321                locale: Some("en-US".to_owned()),
10322                scope: Some("user".to_owned()),
10323                release_date: None,
10324                package_family_name: None,
10325                upgrade_code: None,
10326                platforms: Vec::new(),
10327                minimum_os_version: Some("10.0.22621.0".to_owned()),
10328                switches: InstallerSwitches::default(),
10329                commands: Vec::new(),
10330                package_dependencies: Vec::new(),
10331                require_explicit_upgrade: false,
10332                nested_installer_type: None,
10333                nested_installer_files: Vec::new(),
10334            },
10335            Installer {
10336                architecture: Some("x64".to_owned()),
10337                installer_type: Some("exe".to_owned()),
10338                url: None,
10339                sha256: None,
10340                product_code: None,
10341                locale: Some("en-US".to_owned()),
10342                scope: Some("user".to_owned()),
10343                release_date: None,
10344                package_family_name: None,
10345                upgrade_code: None,
10346                platforms: Vec::new(),
10347                minimum_os_version: Some("10.0.19041.0".to_owned()),
10348                switches: InstallerSwitches::default(),
10349                commands: Vec::new(),
10350                package_dependencies: Vec::new(),
10351                require_explicit_upgrade: false,
10352                nested_installer_type: None,
10353                nested_installer_files: Vec::new(),
10354            },
10355        ];
10356        let query = PackageQuery {
10357            installer_type: Some("exe".to_owned()),
10358            os_version: Some("10.0.19045.0".to_owned()),
10359            ..PackageQuery::default()
10360        };
10361
10362        let selected = select_installer(&installers, &query).expect("selected installer");
10363        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
10364    }
10365
10366    #[test]
10367    fn derives_correlation_name_candidates() {
10368        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
10369        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
10370        assert!(candidates.contains(&"PowerToys".to_owned()));
10371    }
10372
10373    #[test]
10374    fn correlates_installed_package_to_available_match() {
10375        let installed = InstalledPackage {
10376            name: "PowerToys (Preview) x64".to_owned(),
10377            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
10378            installed_version: "0.98.1".to_owned(),
10379            publisher: None,
10380            scope: Some("Machine".to_owned()),
10381            installer_category: Some("exe".to_owned()),
10382            install_location: None,
10383            package_family_names: Vec::new(),
10384            product_codes: Vec::new(),
10385            upgrade_codes: Vec::new(),
10386            correlated: None,
10387            installed_version_canonical: false,
10388            correlated_requires_explicit_upgrade: false,
10389            correlated_lacks_compatible_installer: false,
10390        };
10391        let candidates = vec![SearchMatch {
10392            source_name: "winget".to_owned(),
10393            source_kind: SourceKind::PreIndexed,
10394            id: "Microsoft.PowerToys".to_owned(),
10395            name: "PowerToys".to_owned(),
10396            moniker: None,
10397            version: Some("0.98.1".to_owned()),
10398            channel: None,
10399            match_criteria: None,
10400        }];
10401
10402        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
10403        assert_eq!(correlated.id, "Microsoft.PowerToys");
10404    }
10405
10406    #[test]
10407    fn list_query_uses_available_lookup_for_tag_filters() {
10408        let query = ListQuery {
10409            tag: Some("terminal".to_owned()),
10410            ..ListQuery::default()
10411        };
10412
10413        assert!(list_query_needs_available_lookup(&query));
10414    }
10415
10416    #[test]
10417    fn list_tag_filter_requires_correlated_match() {
10418        let package = InstalledPackage {
10419            name: "PowerToys (Preview) x64".to_owned(),
10420            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
10421            installed_version: "0.98.1".to_owned(),
10422            publisher: None,
10423            scope: Some("Machine".to_owned()),
10424            installer_category: Some("exe".to_owned()),
10425            install_location: None,
10426            package_family_names: Vec::new(),
10427            product_codes: Vec::new(),
10428            upgrade_codes: Vec::new(),
10429            correlated: None,
10430            installed_version_canonical: false,
10431            correlated_requires_explicit_upgrade: false,
10432            correlated_lacks_compatible_installer: false,
10433        };
10434        let query = ListQuery {
10435            tag: Some("powertoys".to_owned()),
10436            ..ListQuery::default()
10437        };
10438
10439        assert!(!list_package_matches(&package, &query));
10440    }
10441
10442    #[test]
10443    fn list_lookup_ignores_display_count() {
10444        let query = ListQuery {
10445            query: Some("git".to_owned()),
10446            count: Some(5),
10447            ..ListQuery::default()
10448        };
10449
10450        let package_query = package_query_from_list_query(&query);
10451        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
10452    }
10453
10454    #[test]
10455    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
10456        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
10457        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
10458        // whichever candidate happened to come last in iteration order. The installed
10459        // display name's prefix decides the correct one.
10460        let installed = InstalledPackage {
10461            name: "Notepad++ (ARM 64-bit)".to_owned(),
10462            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
10463            installed_version: "8.8.9".to_owned(),
10464            publisher: None,
10465            scope: Some("Machine".to_owned()),
10466            installer_category: Some("exe".to_owned()),
10467            install_location: None,
10468            package_family_names: Vec::new(),
10469            product_codes: Vec::new(),
10470            upgrade_codes: Vec::new(),
10471            correlated: None,
10472            installed_version_canonical: false,
10473            correlated_requires_explicit_upgrade: false,
10474            correlated_lacks_compatible_installer: false,
10475        };
10476        let candidates = vec![
10477            SearchMatch {
10478                source_name: "winget".to_owned(),
10479                source_kind: SourceKind::PreIndexed,
10480                id: "ndd.Notepad--".to_owned(),
10481                name: "Notepad--".to_owned(),
10482                moniker: None,
10483                version: Some("1.0.0".to_owned()),
10484                channel: None,
10485                match_criteria: None,
10486            },
10487            SearchMatch {
10488                source_name: "winget".to_owned(),
10489                source_kind: SourceKind::PreIndexed,
10490                id: "Notepad++.Notepad++".to_owned(),
10491                name: "Notepad++".to_owned(),
10492                moniker: None,
10493                version: Some("8.9.5".to_owned()),
10494                channel: None,
10495                match_criteria: None,
10496            },
10497        ];
10498
10499        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
10500        assert_eq!(correlated.id, "Notepad++.Notepad++");
10501    }
10502
10503    #[test]
10504    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
10505        // Without the prefix bonus, the loose substring match picked up `Studio`
10506        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
10507        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
10508        // sort order favored the alphabetically-later candidate.
10509        let installed = InstalledPackage {
10510            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
10511            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
10512            installed_version: "40.10.18029".to_owned(),
10513            publisher: None,
10514            scope: Some("Machine".to_owned()),
10515            installer_category: Some("msi".to_owned()),
10516            install_location: None,
10517            package_family_names: Vec::new(),
10518            product_codes: Vec::new(),
10519            upgrade_codes: Vec::new(),
10520            correlated: None,
10521            installed_version_canonical: false,
10522            correlated_requires_explicit_upgrade: false,
10523            correlated_lacks_compatible_installer: false,
10524        };
10525        let candidates = vec![
10526            SearchMatch {
10527                source_name: "winget".to_owned(),
10528                source_kind: SourceKind::PreIndexed,
10529                id: "ZeroBrane.Studio".to_owned(),
10530                name: "Studio".to_owned(),
10531                moniker: None,
10532                version: Some("1.0".to_owned()),
10533                channel: None,
10534                match_criteria: None,
10535            },
10536            SearchMatch {
10537                source_name: "winget".to_owned(),
10538                source_kind: SourceKind::PreIndexed,
10539                id: "BrickLink.Studio".to_owned(),
10540                name: "Studio".to_owned(),
10541                moniker: None,
10542                version: Some("1.0".to_owned()),
10543                channel: None,
10544                match_criteria: None,
10545            },
10546            SearchMatch {
10547                source_name: "winget".to_owned(),
10548                source_kind: SourceKind::PreIndexed,
10549                id: "Microsoft.DotNet.SDK.10".to_owned(),
10550                name: "Microsoft .NET SDK 10.0".to_owned(),
10551                moniker: None,
10552                version: Some("10.0.204".to_owned()),
10553                channel: None,
10554                match_criteria: None,
10555            },
10556        ];
10557
10558        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
10559        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
10560    }
10561
10562    #[test]
10563    fn msix_packages_do_not_correlate_via_name() {
10564        // MSIX correlation must go through the v2 index's `pfns2` table — name
10565        // fallback is wrong because two MSIX packages can legitimately share a
10566        // display name without sharing identity (`Microsoft Edge Stable` MSIX
10567        // vs the catalog `Microsoft.Edge` MSI, `Notepad++` Store stub MSIX vs
10568        // the catalog Inno installer). Even when the names match exactly the
10569        // catalog `Microsoft.Teams` doesn't represent the same MSIX without a
10570        // PFN hit; that lookup happens in `correlate_installed_via_index`.
10571        let installed = InstalledPackage {
10572            name: "Microsoft Teams".to_owned(),
10573            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
10574            installed_version: "25290.205.4069.4894".to_owned(),
10575            publisher: None,
10576            scope: Some("User".to_owned()),
10577            installer_category: Some("msix".to_owned()),
10578            install_location: None,
10579            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
10580            product_codes: Vec::new(),
10581            upgrade_codes: Vec::new(),
10582            correlated: None,
10583            installed_version_canonical: false,
10584            correlated_requires_explicit_upgrade: false,
10585            correlated_lacks_compatible_installer: false,
10586        };
10587        let candidates = vec![SearchMatch {
10588            source_name: "winget".to_owned(),
10589            source_kind: SourceKind::PreIndexed,
10590            id: "Microsoft.Teams".to_owned(),
10591            name: "Microsoft Teams".to_owned(),
10592            moniker: None,
10593            version: Some("26106.1906.4665.7308".to_owned()),
10594            channel: None,
10595            match_criteria: None,
10596        }];
10597
10598        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
10599    }
10600
10601    #[test]
10602    fn name_fallback_refuses_ambiguous_winners() {
10603        // The user has Git installed (publisher "The Git Development Community"
10604        // — but publisher isn't in the scoring function). Two catalog packages
10605        // both expose name "Git": `Git.Git` and `Microsoft.Git`. Without
10606        // publisher disambiguation they score identically; winget refuses to
10607        // correlate (the install lists with empty Source). pinget must do the
10608        // same to avoid manufacturing an upgrade against the wrong catalog
10609        // package.
10610        let installed = InstalledPackage {
10611            name: "Git".to_owned(),
10612            local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
10613            installed_version: "2.53.0".to_owned(),
10614            publisher: Some("The Git Development Community".to_owned()),
10615            scope: Some("Machine".to_owned()),
10616            installer_category: Some("exe".to_owned()),
10617            install_location: None,
10618            package_family_names: Vec::new(),
10619            product_codes: Vec::new(),
10620            upgrade_codes: Vec::new(),
10621            correlated: None,
10622            installed_version_canonical: false,
10623            correlated_requires_explicit_upgrade: false,
10624            correlated_lacks_compatible_installer: false,
10625        };
10626        let candidates = vec![
10627            SearchMatch {
10628                source_name: "winget".to_owned(),
10629                source_kind: SourceKind::PreIndexed,
10630                id: "Git.Git".to_owned(),
10631                name: "Git".to_owned(),
10632                moniker: None,
10633                version: Some("2.54.0".to_owned()),
10634                channel: None,
10635                match_criteria: None,
10636            },
10637            SearchMatch {
10638                source_name: "winget".to_owned(),
10639                source_kind: SourceKind::PreIndexed,
10640                id: "Microsoft.Git".to_owned(),
10641                name: "Git".to_owned(),
10642                moniker: None,
10643                version: Some("2.53.0.0.7".to_owned()),
10644                channel: None,
10645                match_criteria: None,
10646            },
10647        ];
10648
10649        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
10650    }
10651
10652    #[test]
10653    fn msix_with_resource_string_name_does_not_correlate() {
10654        // Some MSIX entries have unresolved resource-string display names
10655        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
10656        // unrelated catalog package via the loose substring rule.
10657        let installed = InstalledPackage {
10658            name: "ms-resource:appDisplayName".to_owned(),
10659            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
10660            installed_version: "1.28.239.0".to_owned(),
10661            publisher: None,
10662            scope: Some("User".to_owned()),
10663            installer_category: Some("msix".to_owned()),
10664            install_location: None,
10665            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
10666            product_codes: Vec::new(),
10667            upgrade_codes: Vec::new(),
10668            correlated: None,
10669            installed_version_canonical: false,
10670            correlated_requires_explicit_upgrade: false,
10671            correlated_lacks_compatible_installer: false,
10672        };
10673        let candidates = vec![SearchMatch {
10674            source_name: "winget".to_owned(),
10675            source_kind: SourceKind::PreIndexed,
10676            id: "Microsoft.AppInstaller".to_owned(),
10677            name: "App Installer".to_owned(),
10678            moniker: None,
10679            version: Some("1.28.240.0".to_owned()),
10680            channel: None,
10681            match_criteria: None,
10682        }];
10683
10684        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
10685    }
10686
10687    #[test]
10688    fn strict_list_correlation_avoids_short_substring_matches() {
10689        let installed = InstalledPackage {
10690            name: "GitHub".to_owned(),
10691            local_id: r"ARP\User\X64\GitHub".to_owned(),
10692            installed_version: "1.0.0".to_owned(),
10693            publisher: None,
10694            scope: Some("User".to_owned()),
10695            installer_category: Some("exe".to_owned()),
10696            install_location: None,
10697            package_family_names: Vec::new(),
10698            product_codes: Vec::new(),
10699            upgrade_codes: Vec::new(),
10700            correlated: None,
10701            installed_version_canonical: false,
10702            correlated_requires_explicit_upgrade: false,
10703            correlated_lacks_compatible_installer: false,
10704        };
10705        let candidates = vec![SearchMatch {
10706            source_name: "winget".to_owned(),
10707            source_kind: SourceKind::PreIndexed,
10708            id: "Git.Git.PreRelease".to_owned(),
10709            name: "Git".to_owned(),
10710            moniker: None,
10711            version: Some("2.54.0".to_owned()),
10712            channel: None,
10713            match_criteria: None,
10714        }];
10715
10716        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
10717    }
10718
10719    #[test]
10720    fn detects_available_upgrade_from_correlated_package() {
10721        let package = InstalledPackage {
10722            name: "AzCopy v10".to_owned(),
10723            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
10724            installed_version: "10.32.2".to_owned(),
10725            publisher: None,
10726            scope: Some("Machine".to_owned()),
10727            installer_category: Some("exe".to_owned()),
10728            install_location: None,
10729            package_family_names: Vec::new(),
10730            product_codes: Vec::new(),
10731            upgrade_codes: Vec::new(),
10732            correlated: Some(SearchMatch {
10733                source_name: "winget".to_owned(),
10734                source_kind: SourceKind::PreIndexed,
10735                id: "Microsoft.Azure.AZCopy.10".to_owned(),
10736                name: "AzCopy v10".to_owned(),
10737                moniker: None,
10738                version: Some("10.32.3".to_owned()),
10739                channel: None,
10740                match_criteria: None,
10741            }),
10742            installed_version_canonical: false,
10743            correlated_requires_explicit_upgrade: false,
10744            correlated_lacks_compatible_installer: false,
10745        };
10746
10747        assert!(installed_package_has_upgrade(&package));
10748    }
10749
10750    #[test]
10751    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
10752        let package = InstalledPackage {
10753            name: "Example Tool".to_owned(),
10754            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
10755            installed_version: "Unknown".to_owned(),
10756            publisher: None,
10757            scope: Some("User".to_owned()),
10758            installer_category: Some("exe".to_owned()),
10759            install_location: None,
10760            package_family_names: Vec::new(),
10761            product_codes: Vec::new(),
10762            upgrade_codes: Vec::new(),
10763            correlated: Some(SearchMatch {
10764                source_name: "winget".to_owned(),
10765                source_kind: SourceKind::PreIndexed,
10766                id: "Contoso.ExampleTool".to_owned(),
10767                name: "Example Tool".to_owned(),
10768                moniker: None,
10769                version: Some("2.0.0".to_owned()),
10770                channel: None,
10771                match_criteria: None,
10772            }),
10773            installed_version_canonical: false,
10774            correlated_requires_explicit_upgrade: false,
10775            correlated_lacks_compatible_installer: false,
10776        };
10777        let query = ListQuery {
10778            upgrade_only: true,
10779            include_unknown: true,
10780            ..Default::default()
10781        };
10782
10783        assert!(installed_package_matches_upgrade_filter(&package, &query));
10784        assert_eq!(
10785            list_match_from_installed(package).available_version.as_deref(),
10786            Some("2.0.0")
10787        );
10788    }
10789
10790    #[test]
10791    fn create_repair_list_query_includes_installed_selectors() {
10792        let request = RepairRequest {
10793            query: PackageQuery {
10794                query: Some("powertoys".to_owned()),
10795                id: Some("Microsoft.PowerToys".to_owned()),
10796                name: Some("PowerToys".to_owned()),
10797                moniker: Some("powertoys".to_owned()),
10798                source: Some("winget".to_owned()),
10799                exact: true,
10800                version: Some("0.98.1".to_owned()),
10801                install_scope: Some("Machine".to_owned()),
10802                ..PackageQuery::default()
10803            },
10804            manifest_path: None,
10805            product_code: Some("{GUID}".to_owned()),
10806            mode: InstallerMode::Silent,
10807            log_path: Some(PathBuf::from("repair.log")),
10808            accept_package_agreements: true,
10809            force: true,
10810            ignore_security_hash: true,
10811        };
10812
10813        let query = Repository::create_repair_list_query(&request);
10814        assert_eq!(query.query.as_deref(), Some("powertoys"));
10815        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
10816        assert_eq!(query.name.as_deref(), Some("PowerToys"));
10817        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
10818        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
10819        assert_eq!(query.version.as_deref(), Some("0.98.1"));
10820        assert_eq!(query.source.as_deref(), Some("winget"));
10821        assert!(query.exact);
10822        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
10823        assert_eq!(query.count, Some(100));
10824    }
10825
10826    #[test]
10827    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
10828        let request = RepairRequest {
10829            query: PackageQuery {
10830                name: Some("PowerToys".to_owned()),
10831                source: Some("winget".to_owned()),
10832                locale: Some("en-US".to_owned()),
10833                installer_architecture: Some("x64".to_owned()),
10834                install_scope: Some("Machine".to_owned()),
10835                ..PackageQuery::default()
10836            },
10837            manifest_path: None,
10838            product_code: None,
10839            mode: InstallerMode::SilentWithProgress,
10840            log_path: Some(PathBuf::from("repair.log")),
10841            accept_package_agreements: true,
10842            force: false,
10843            ignore_security_hash: true,
10844        };
10845        let installed = ListMatch {
10846            name: "PowerToys".to_owned(),
10847            id: "Microsoft.PowerToys".to_owned(),
10848            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
10849            installed_version: "0.98.1".to_owned(),
10850            available_version: Some("0.98.2".to_owned()),
10851            source_name: Some("winget".to_owned()),
10852            publisher: None,
10853            scope: Some("Machine".to_owned()),
10854            installer_category: Some("exe".to_owned()),
10855            install_location: None,
10856            package_family_names: Vec::new(),
10857            product_codes: Vec::new(),
10858            upgrade_codes: Vec::new(),
10859        };
10860
10861        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
10862        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
10863        assert!(install_request.query.query.is_none());
10864        assert!(install_request.query.name.is_none());
10865        assert!(install_request.query.moniker.is_none());
10866        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
10867        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
10868        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
10869        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
10870        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
10871        assert!(install_request.query.exact);
10872        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
10873        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
10874        assert!(install_request.accept_package_agreements);
10875        assert!(install_request.force);
10876        assert!(install_request.ignore_security_hash);
10877    }
10878
10879    #[test]
10880    fn source_scoped_pin_operations_round_trip() {
10881        let app_root = temp_app_root("pins");
10882        let result = (|| -> Result<()> {
10883            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
10884            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
10885            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
10886            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
10887
10888            assert_eq!(repository.list_pins(None)?.len(), 3);
10889            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
10890            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
10891
10892            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
10893            assert_eq!(repository.list_pins(None)?.len(), 2);
10894            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
10895
10896            repository.reset_pins(Some("test"))?;
10897            let remaining = repository.list_pins(None)?;
10898            assert_eq!(remaining.len(), 1);
10899            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
10900
10901            repository.reset_pins(None)?;
10902            assert!(repository.list_pins(None)?.is_empty());
10903            Ok(())
10904        })();
10905
10906        let _ = fs::remove_dir_all(&app_root);
10907        result.expect("pin round trip");
10908    }
10909
10910    #[test]
10911    fn pin_operations_work_with_packaged_pin_schema() {
10912        let app_root = temp_app_root("pins_packaged_schema");
10913        let result = (|| -> Result<()> {
10914            let db_path = pins_db_path(&app_root);
10915            if let Some(parent) = db_path.parent() {
10916                fs::create_dir_all(parent)?;
10917            }
10918
10919            let conn = Connection::open(&db_path)?;
10920            conn.execute_batch(
10921                "CREATE TABLE pin (
10922                    package_id TEXT NOT NULL,
10923                    source_id TEXT NOT NULL,
10924                    type INTEGER NOT NULL,
10925                    version TEXT NOT NULL,
10926                    PRIMARY KEY (package_id, source_id)
10927                )",
10928            )?;
10929            drop(conn);
10930
10931            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
10932            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
10933
10934            let pins = repository.list_pins(None)?;
10935            assert_eq!(pins.len(), 1);
10936            assert_eq!(pins[0].package_id, "Contoso.Tool");
10937            assert_eq!(pins[0].version, "1.2.*");
10938            assert_eq!(pins[0].source_id, "winget");
10939            assert_eq!(pins[0].pin_type, PinType::Gating);
10940
10941            let conn = Connection::open(&db_path)?;
10942            let stored_pin = conn.query_row(
10943                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
10944                rusqlite::params!["Contoso.Tool", "winget"],
10945                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
10946            )?;
10947            assert_eq!(stored_pin.0, 3);
10948            assert_eq!(stored_pin.1, "1.2.*");
10949
10950            Ok(())
10951        })();
10952
10953        let _ = fs::remove_dir_all(&app_root);
10954        result.expect("packaged pin schema round trip");
10955    }
10956
10957    #[test]
10958    fn version_pin_patterns_match_exact_and_prefix_values() {
10959        assert!(version_matches_pin_pattern("1.2.3", "*"));
10960        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
10961        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
10962        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
10963        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
10964    }
10965
10966    #[test]
10967    fn source_specific_pins_override_source_agnostic_pins() {
10968        let item = ListMatch {
10969            name: "Contoso Tool".to_owned(),
10970            id: "Contoso.Tool".to_owned(),
10971            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
10972            installed_version: "1.0.0".to_owned(),
10973            available_version: Some("2.0.0".to_owned()),
10974            source_name: Some("winget".to_owned()),
10975            publisher: None,
10976            scope: None,
10977            installer_category: None,
10978            install_location: None,
10979            package_family_names: Vec::new(),
10980            product_codes: Vec::new(),
10981            upgrade_codes: Vec::new(),
10982        };
10983        let pins = vec![
10984            PinRecord {
10985                package_id: "Contoso.Tool".to_owned(),
10986                version: "*".to_owned(),
10987                source_id: String::new(),
10988                pin_type: PinType::Blocking,
10989            },
10990            PinRecord {
10991                package_id: "Contoso.Tool".to_owned(),
10992                version: "2.0.*".to_owned(),
10993                source_id: "winget".to_owned(),
10994                pin_type: PinType::Pinning,
10995            },
10996        ];
10997
10998        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
10999    }
11000
11001    #[test]
11002    fn blocking_pin_blocks_upgrade() {
11003        let item = ListMatch {
11004            name: "Contoso Tool".to_owned(),
11005            id: "Contoso.Tool".to_owned(),
11006            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11007            installed_version: "1.0.0".to_owned(),
11008            available_version: Some("2.0.0".to_owned()),
11009            source_name: Some("winget".to_owned()),
11010            publisher: None,
11011            scope: None,
11012            installer_category: None,
11013            install_location: None,
11014            package_family_names: Vec::new(),
11015            product_codes: Vec::new(),
11016            upgrade_codes: Vec::new(),
11017        };
11018        let pins = vec![PinRecord {
11019            package_id: "Contoso.Tool".to_owned(),
11020            version: "*".to_owned(),
11021            source_id: "winget".to_owned(),
11022            pin_type: PinType::Blocking,
11023        }];
11024
11025        assert!(is_upgrade_blocked_by_pin(&item, &pins));
11026    }
11027
11028    #[cfg(windows)]
11029    #[test]
11030    fn parses_msix_package_full_name_into_version_and_family() {
11031        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
11032            .expect("package metadata");
11033
11034        assert_eq!(parsed.version, "0.98.1.0");
11035        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
11036    }
11037
11038    #[cfg(windows)]
11039    #[test]
11040    fn recognizes_windows_system_paths() {
11041        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
11042        assert!(!is_windows_system_path(
11043            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
11044        ));
11045    }
11046
11047    #[test]
11048    fn search_ranking_prefers_exact_name_over_tag_match() {
11049        let query = PackageQuery {
11050            query: Some("PowerToys".to_owned()),
11051            ..Default::default()
11052        };
11053        let exact_name = SearchMatch {
11054            source_name: "winget".to_owned(),
11055            source_kind: SourceKind::PreIndexed,
11056            id: "Microsoft.PowerToys".to_owned(),
11057            name: "PowerToys".to_owned(),
11058            moniker: Some("powertoys".to_owned()),
11059            version: Some("0.98.1".to_owned()),
11060            channel: None,
11061            match_criteria: Some("Moniker: powertoys".to_owned()),
11062        };
11063        let tag_match = SearchMatch {
11064            source_name: "winget".to_owned(),
11065            source_kind: SourceKind::PreIndexed,
11066            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
11067            name: "QR Codes for Command Palette".to_owned(),
11068            moniker: None,
11069            version: Some("0.4.0.0".to_owned()),
11070            channel: None,
11071            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
11072        };
11073
11074        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
11075    }
11076
11077    #[test]
11078    fn select_best_text_match_prefers_exact_tag_value() {
11079        let values = vec![
11080            "microsoft-powertoys".to_owned(),
11081            "powertoys-run".to_owned(),
11082            "powertoys".to_owned(),
11083        ];
11084
11085        assert_eq!(
11086            select_best_text_match(values, "PowerToys", false).as_deref(),
11087            Some("powertoys")
11088        );
11089    }
11090
11091    #[test]
11092    fn search_match_criteria_is_blank_for_name_match() {
11093        let query = PackageQuery {
11094            query: Some("PowerToys".to_owned()),
11095            ..Default::default()
11096        };
11097
11098        let result = infer_match_criteria(
11099            "Microsoft.PowerToys",
11100            "PowerToys",
11101            Some("powertoys"),
11102            &query,
11103            SearchSemantics::Many,
11104            |_| Ok(Some("powertoys".to_owned())),
11105            |_| Ok(None),
11106        )
11107        .expect("match criteria");
11108
11109        assert_eq!(result, None);
11110    }
11111
11112    #[test]
11113    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
11114        let query = PackageQuery {
11115            query: Some("PowerToys".to_owned()),
11116            ..Default::default()
11117        };
11118        let unknown_version = SearchMatch {
11119            source_name: "msstore".to_owned(),
11120            source_kind: SourceKind::Rest,
11121            id: "XP89DCGQ3K6VLD".to_owned(),
11122            name: "Microsoft PowerToys".to_owned(),
11123            moniker: None,
11124            version: Some("Unknown".to_owned()),
11125            channel: None,
11126            match_criteria: None,
11127        };
11128        let tag_match = SearchMatch {
11129            source_name: "winget".to_owned(),
11130            source_kind: SourceKind::PreIndexed,
11131            id: "Riri.QRCodeforCmdPal".to_owned(),
11132            name: "QR Code for CmdPal".to_owned(),
11133            moniker: None,
11134            version: Some("0.0.1.0".to_owned()),
11135            channel: None,
11136            match_criteria: Some("Tag: powertoys".to_owned()),
11137        };
11138
11139        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
11140    }
11141
11142    #[test]
11143    fn search_source_fetch_results_ignore_small_display_count() {
11144        let query = PackageQuery {
11145            count: Some(5),
11146            ..Default::default()
11147        };
11148
11149        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
11150        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
11151    }
11152
11153    #[test]
11154    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
11155        let query = PackageQuery {
11156            query: Some("PowerToys".to_owned()),
11157            ..Default::default()
11158        };
11159        let prefix_name = SearchMatch {
11160            source_name: "winget".to_owned(),
11161            source_kind: SourceKind::PreIndexed,
11162            id: "advaith.CurrencyConverterPowerToys".to_owned(),
11163            name: "PowerToys-Run-Currency-Converter".to_owned(),
11164            moniker: None,
11165            version: Some("1.5.4".to_owned()),
11166            channel: None,
11167            match_criteria: None,
11168        };
11169        let exact_tag = SearchMatch {
11170            source_name: "winget".to_owned(),
11171            source_kind: SourceKind::PreIndexed,
11172            id: "Riri.QRCodeforCmdPal".to_owned(),
11173            name: "QR Code for CmdPal".to_owned(),
11174            moniker: None,
11175            version: Some("0.0.1.0".to_owned()),
11176            channel: None,
11177            match_criteria: Some("Tag: powertoys".to_owned()),
11178        };
11179
11180        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
11181    }
11182
11183    #[test]
11184    fn list_sort_prefers_main_package_then_sparse_app() {
11185        let main = InstalledPackage {
11186            name: "PowerToys (Preview) x64".to_owned(),
11187            local_id: r"ARP\User\X64\PowerToys".to_owned(),
11188            installed_version: "0.98.1".to_owned(),
11189            publisher: None,
11190            scope: Some("User".to_owned()),
11191            installer_category: Some("exe".to_owned()),
11192            install_location: None,
11193            package_family_names: Vec::new(),
11194            product_codes: Vec::new(),
11195            upgrade_codes: Vec::new(),
11196            correlated: None,
11197            installed_version_canonical: false,
11198            correlated_requires_explicit_upgrade: false,
11199            correlated_lacks_compatible_installer: false,
11200        };
11201        let sparse = InstalledPackage {
11202            name: "PowerToys.SparseApp".to_owned(),
11203            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
11204            installed_version: "0.98.1.0".to_owned(),
11205            publisher: None,
11206            scope: Some("User".to_owned()),
11207            installer_category: Some("msix".to_owned()),
11208            install_location: None,
11209            package_family_names: Vec::new(),
11210            product_codes: Vec::new(),
11211            upgrade_codes: Vec::new(),
11212            correlated: None,
11213            installed_version_canonical: false,
11214            correlated_requires_explicit_upgrade: false,
11215            correlated_lacks_compatible_installer: false,
11216        };
11217        let extension = InstalledPackage {
11218            name: "PowerToys FileLocksmith Context Menu".to_owned(),
11219            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
11220            installed_version: "0.98.1.0".to_owned(),
11221            publisher: None,
11222            scope: Some("User".to_owned()),
11223            installer_category: Some("msix".to_owned()),
11224            install_location: None,
11225            package_family_names: Vec::new(),
11226            product_codes: Vec::new(),
11227            upgrade_codes: Vec::new(),
11228            correlated: None,
11229            installed_version_canonical: false,
11230            correlated_requires_explicit_upgrade: false,
11231            correlated_lacks_compatible_installer: false,
11232        };
11233
11234        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
11235        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
11236    }
11237
11238    #[test]
11239    #[cfg(windows)]
11240    fn arp_subkey_name_extracts_registry_subkey() {
11241        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
11242        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
11243    }
11244
11245    #[test]
11246    #[cfg(windows)]
11247    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
11248        let installed = ListMatch {
11249            name: "ShareX".to_owned(),
11250            id: "ShareX.ShareX".to_owned(),
11251            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
11252            installed_version: "19.0.2".to_owned(),
11253            available_version: None,
11254            source_name: Some("winget".to_owned()),
11255            publisher: Some("ShareX Team".to_owned()),
11256            scope: Some("Machine".to_owned()),
11257            installer_category: Some("exe".to_owned()),
11258            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
11259            package_family_names: Vec::new(),
11260            product_codes: Vec::new(),
11261            upgrade_codes: Vec::new(),
11262        };
11263
11264        assert!(registry_entry_matches_installed_package(
11265            "ShareX", "ShareX", None, &installed
11266        ));
11267        assert!(!registry_entry_matches_installed_package(
11268            "ShareX.ShareX",
11269            "ShareX.ShareX",
11270            None,
11271            &installed
11272        ));
11273    }
11274
11275    #[test]
11276    #[cfg(windows)]
11277    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
11278        assert_eq!(
11279            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
11280            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
11281        );
11282        assert_eq!(
11283            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
11284            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
11285        );
11286        assert_eq!(
11287            r#""C:\Program Files\ShareX\unins000.exe""#,
11288            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
11289        );
11290        assert_eq!(
11291            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
11292            build_uninstall_command(
11293                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
11294                true,
11295                false,
11296            )
11297        );
11298    }
11299
11300    #[test]
11301    #[cfg(windows)]
11302    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
11303        let installed = ListMatch {
11304            name: "lazygit".to_owned(),
11305            id: "JesseDuffield.lazygit".to_owned(),
11306            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
11307            installed_version: "0.61.1".to_owned(),
11308            available_version: None,
11309            source_name: Some("winget".to_owned()),
11310            publisher: Some("Jesse Duffield".to_owned()),
11311            scope: Some("User".to_owned()),
11312            installer_category: Some("exe".to_owned()),
11313            install_location: Some(
11314                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
11315            ),
11316            package_family_names: Vec::new(),
11317            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
11318            upgrade_codes: Vec::new(),
11319        };
11320        let mut request = UninstallRequest::new(PackageQuery {
11321            id: Some("JesseDuffield.lazygit".to_owned()),
11322            install_scope: Some("user".to_owned()),
11323            ..PackageQuery::default()
11324        });
11325        request.mode = InstallerMode::Silent;
11326
11327        assert_eq!(
11328            Some(vec![
11329                "uninstall".to_owned(),
11330                "--id".to_owned(),
11331                "JesseDuffield.lazygit".to_owned(),
11332                "--exact".to_owned(),
11333                "--disable-interactivity".to_owned(),
11334                "--source".to_owned(),
11335                "winget".to_owned(),
11336                "--scope".to_owned(),
11337                "user".to_owned(),
11338                "--silent".to_owned(),
11339            ]),
11340            build_winget_uninstall_arguments(&installed, &request)
11341        );
11342
11343        assert_eq!(
11344            Some(vec![
11345                "uninstall".to_owned(),
11346                "--id".to_owned(),
11347                "JesseDuffield.lazygit".to_owned(),
11348                "--exact".to_owned(),
11349                "--disable-interactivity".to_owned(),
11350                "--source".to_owned(),
11351                "winget".to_owned(),
11352                "--silent".to_owned(),
11353            ]),
11354            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
11355        );
11356    }
11357
11358    fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
11359        PackageVersionDataEntry {
11360            version: version.to_owned(),
11361            manifest_relative_path: format!("manifests/x/{version}"),
11362            manifest_hash: "00".repeat(32),
11363            arp_min_version: arp_min.map(str::to_owned),
11364            arp_max_version: arp_max.map(str::to_owned),
11365        }
11366    }
11367
11368    #[test]
11369    fn map_arp_version_returns_catalog_version_inside_range() {
11370        // Real winget data: .NET SDK 10.0.108 declares its ARP DisplayVersion is
11371        // `10.1.826.23019`. compare_version on that against the catalog Version
11372        // `10.0.108` claims installed > available, so without this mapping the
11373        // upgrade was silently dropped.
11374        let entries = vec![
11375            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
11376            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
11377            version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
11378        ];
11379
11380        assert_eq!(
11381            map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
11382            Some("10.0.108")
11383        );
11384    }
11385
11386    #[test]
11387    fn map_arp_version_returns_none_when_no_range_matches() {
11388        // VS-installed .NET SDK reports ARP DisplayVersion `40.10.18029` which
11389        // doesn't sit inside any aMiV..aMaV bucket. Caller must keep the
11390        // original installed version (and accept that no upgrade can be
11391        // computed) — same as winget's behavior.
11392        let entries = vec![
11393            version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
11394            version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
11395        ];
11396
11397        assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
11398    }
11399
11400    #[test]
11401    fn map_arp_version_skips_entries_missing_arp_bounds() {
11402        // Older catalog packages predate AppsAndFeaturesEntries and have no
11403        // aMiV/aMaV. Those entries must be ignored — not silently treated as
11404        // an unbounded range.
11405        let entries = vec![
11406            version_entry("9.0.300", None, None),
11407            version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
11408        ];
11409
11410        assert_eq!(
11411            map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
11412            Some("9.0.117")
11413        );
11414        assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
11415    }
11416
11417    #[test]
11418    fn map_arp_version_handles_inclusive_range_endpoints() {
11419        // aMiV/aMaV are inclusive bounds — a version equal to either endpoint
11420        // must still map.
11421        let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
11422
11423        assert_eq!(
11424            map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
11425            Some("3.14.5")
11426        );
11427        assert_eq!(
11428            map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
11429            Some("3.14.5")
11430        );
11431        assert_eq!(
11432            map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
11433            Some("3.14.5")
11434        );
11435    }
11436
11437    #[test]
11438    fn map_arp_version_ignores_unknown_marker() {
11439        // collect_installed_packages writes the literal "Unknown" when ARP has
11440        // no DisplayVersion. Trying to map that would falsely match the first
11441        // entry whose aMiV..aMaV string-compares to include "Unknown".
11442        let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
11443
11444        assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
11445        assert!(map_arp_version_to_catalog(&entries, "").is_none());
11446    }
11447
11448    #[test]
11449    fn latest_arp_anchored_skips_internal_versions() {
11450        // Microsoft.WindowsAppRuntime.1.8 publishes both an internal build
11451        // version (`8000.836.2153.0`, no ARP bounds) and user-facing versions
11452        // (`1.8.6`, `1.8.5`, …). The internal row shouldn't win — comparing
11453        // it against an installed canonical of `1.8.6` would falsely report
11454        // an upgrade. Returns the highest anchored Version instead.
11455        let entries = vec![
11456            version_entry("8000.836.2153.0", None, None),
11457            version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
11458            version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
11459            version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
11460        ];
11461
11462        assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
11463    }
11464
11465    #[test]
11466    fn latest_arp_anchored_returns_none_without_bounds() {
11467        // App Installer's versionData has no aMiV/aMaV — every version is a
11468        // user-facing MSIX Version. Caller must fall back to
11469        // `packages.latest_version` in this case.
11470        let entries = vec![
11471            version_entry("1.28.240.0", None, None),
11472            version_entry("1.27.470.0", None, None),
11473        ];
11474
11475        assert!(latest_arp_anchored_version(&entries).is_none());
11476    }
11477
11478    #[test]
11479    fn list_match_from_installed_prefers_correlated_id_and_source_name() {
11480        let package = InstalledPackage {
11481            name: "AzCopy v10".to_owned(),
11482            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
11483            installed_version: "10.32.2".to_owned(),
11484            publisher: Some("Microsoft".to_owned()),
11485            scope: Some("Machine".to_owned()),
11486            installer_category: Some("exe".to_owned()),
11487            install_location: None,
11488            package_family_names: Vec::new(),
11489            product_codes: Vec::new(),
11490            upgrade_codes: Vec::new(),
11491            correlated: Some(SearchMatch {
11492                source_name: "winget".to_owned(),
11493                source_kind: SourceKind::PreIndexed,
11494                id: "Microsoft.Azure.AZCopy.10".to_owned(),
11495                name: "AzCopy v10".to_owned(),
11496                moniker: None,
11497                version: Some("10.32.3".to_owned()),
11498                channel: None,
11499                match_criteria: None,
11500            }),
11501            installed_version_canonical: false,
11502            correlated_requires_explicit_upgrade: false,
11503            correlated_lacks_compatible_installer: false,
11504        };
11505
11506        let item = list_match_from_installed(package);
11507
11508        assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
11509        assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
11510        assert_eq!(item.source_name.as_deref(), Some("winget"));
11511        assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
11512    }
11513
11514    #[test]
11515    fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
11516        let package = InstalledPackage {
11517            name: "Atlassian CLI".to_owned(),
11518            local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
11519            installed_version: "1.3.18-stable".to_owned(),
11520            publisher: Some("Atlassian".to_owned()),
11521            scope: Some("User".to_owned()),
11522            installer_category: Some("exe".to_owned()),
11523            install_location: None,
11524            package_family_names: Vec::new(),
11525            product_codes: Vec::new(),
11526            upgrade_codes: Vec::new(),
11527            correlated: Some(SearchMatch {
11528                source_name: "winget".to_owned(),
11529                source_kind: SourceKind::PreIndexed,
11530                id: "Atlassian.AtlassianCLI".to_owned(),
11531                name: "Atlassian CLI".to_owned(),
11532                moniker: None,
11533                version: Some("1.3.18-stable".to_owned()),
11534                channel: None,
11535                match_criteria: Some("ProductCode".to_owned()),
11536            }),
11537            installed_version_canonical: false,
11538            correlated_requires_explicit_upgrade: false,
11539            correlated_lacks_compatible_installer: false,
11540        };
11541
11542        let item = list_match_from_installed(package);
11543
11544        assert_eq!(item.id, "Atlassian.AtlassianCLI");
11545        assert_eq!(
11546            item.local_id,
11547            r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
11548        );
11549        assert_ne!(item.id, item.local_id);
11550        assert_eq!(item.source_name.as_deref(), Some("winget"));
11551    }
11552
11553    #[test]
11554    fn list_response_serialization_keeps_pascal_case_fields() {
11555        let response = ListResponse {
11556            matches: vec![ListMatch {
11557                name: "PowerToys".to_owned(),
11558                id: "Microsoft.PowerToys".to_owned(),
11559                local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11560                installed_version: "0.98.1".to_owned(),
11561                available_version: Some("0.99.0".to_owned()),
11562                source_name: Some("winget".to_owned()),
11563                publisher: Some("Microsoft".to_owned()),
11564                scope: Some("Machine".to_owned()),
11565                installer_category: Some("msi".to_owned()),
11566                install_location: None,
11567                package_family_names: Vec::new(),
11568                product_codes: Vec::new(),
11569                upgrade_codes: Vec::new(),
11570            }],
11571            warnings: Vec::new(),
11572            truncated: false,
11573        };
11574
11575        let value = serde_json::to_value(&response).expect("serialize list response");
11576        let match_value = &value["Matches"][0];
11577
11578        assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
11579        assert_eq!(match_value["InstalledVersion"], "0.98.1");
11580        assert_eq!(match_value["AvailableVersion"], "0.99.0");
11581        assert_eq!(match_value["SourceName"], "winget");
11582        assert!(match_value.get("local_id").is_none());
11583        assert!(match_value.get("installed_version").is_none());
11584        assert!(match_value.get("available_version").is_none());
11585        assert!(match_value.get("source_name").is_none());
11586    }
11587
11588    #[test]
11589    fn search_response_serialization_preserves_minimum_pascal_case_shape() {
11590        let response = SearchResponse {
11591            matches: vec![SearchMatch {
11592                source_name: "msstore".to_owned(),
11593                source_kind: SourceKind::Rest,
11594                id: "9WZDNCRFJBMP".to_owned(),
11595                name: "Microsoft To Do".to_owned(),
11596                moniker: None,
11597                version: Some("2.123.456.0".to_owned()),
11598                channel: None,
11599                match_criteria: None,
11600            }],
11601            warnings: Vec::new(),
11602            truncated: false,
11603        };
11604
11605        let value = serde_json::to_value(&response).expect("serialize search response");
11606        let match_value = &value["Matches"][0];
11607
11608        assert_eq!(match_value["Name"], "Microsoft To Do");
11609        assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
11610        assert_eq!(match_value["Version"], "2.123.456.0");
11611        assert_eq!(match_value["SourceName"], "msstore");
11612        assert!(match_value.get("source_name").is_none());
11613    }
11614
11615    #[test]
11616    fn versions_result_serialization_keeps_pascal_case_fields() {
11617        let result = VersionsResult {
11618            package: SearchMatch {
11619                source_name: "winget".to_owned(),
11620                source_kind: SourceKind::PreIndexed,
11621                id: "Microsoft.PowerToys".to_owned(),
11622                name: "PowerToys".to_owned(),
11623                moniker: None,
11624                version: Some("0.99.0".to_owned()),
11625                channel: None,
11626                match_criteria: None,
11627            },
11628            versions: vec![
11629                VersionKey {
11630                    version: "0.99.0".to_owned(),
11631                    channel: "stable".to_owned(),
11632                },
11633                VersionKey {
11634                    version: "0.100.0-preview".to_owned(),
11635                    channel: "preview".to_owned(),
11636                },
11637            ],
11638            warnings: Vec::new(),
11639        };
11640
11641        let value = serde_json::to_value(&result).expect("serialize versions result");
11642
11643        assert_eq!(value["Versions"][0]["Version"], "0.99.0");
11644        assert_eq!(value["Versions"][0]["Channel"], "stable");
11645        assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
11646        assert_eq!(value["Versions"][1]["Channel"], "preview");
11647        assert!(value.get("versions").is_none());
11648    }
11649
11650    #[test]
11651    fn version_data_parses_arp_bounds_from_winget_payload() {
11652        // Sanity check that aMiV/aMaV deserialize from a real-world
11653        // versionData.mszyml payload shape. Without `default`, packages
11654        // predating AppsAndFeaturesEntries would fail to parse.
11655        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";
11656
11657        let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
11658        assert_eq!(document.versions.len(), 2);
11659        assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
11660        assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
11661        assert!(document.versions[1].arp_min_version.is_none());
11662        assert!(document.versions[1].arp_max_version.is_none());
11663    }
11664
11665    #[test]
11666    fn msix_resource_string_name_resolves_to_catalog_name() {
11667        // App Installer's MSIX manifest stores DisplayName as
11668        // `ms-resource:appDisplayName`. Once we correlate it via PFN, we
11669        // know the catalog calls it `App Installer` — show that instead of
11670        // the unresolved placeholder, matching winget's output.
11671        let mut package = InstalledPackage {
11672            name: "ms-resource:appDisplayName".to_owned(),
11673            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
11674            installed_version: "1.28.239.0".to_owned(),
11675            publisher: None,
11676            scope: Some("User".to_owned()),
11677            installer_category: Some("msix".to_owned()),
11678            install_location: None,
11679            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
11680            product_codes: Vec::new(),
11681            upgrade_codes: Vec::new(),
11682            correlated: Some(SearchMatch {
11683                source_name: "winget".to_owned(),
11684                source_kind: SourceKind::PreIndexed,
11685                id: "Microsoft.AppInstaller".to_owned(),
11686                name: "App Installer".to_owned(),
11687                moniker: None,
11688                version: Some("1.28.240.0".to_owned()),
11689                channel: None,
11690                match_criteria: Some("PackageFamilyName".to_owned()),
11691            }),
11692            installed_version_canonical: false,
11693            correlated_requires_explicit_upgrade: false,
11694            correlated_lacks_compatible_installer: false,
11695        };
11696        apply_msix_resource_string_name_fix(&mut package);
11697        assert_eq!(package.name, "App Installer");
11698    }
11699
11700    #[test]
11701    fn msix_resource_string_fix_is_a_noop_when_not_msix() {
11702        // ARP rows can also carry unusual display names; the fix is gated
11703        // on the local_id prefix so a `ms-resource:` literal that somehow
11704        // appears in an ARP DisplayName (extremely unlikely, but possible)
11705        // doesn't get silently rewritten.
11706        let mut package = InstalledPackage {
11707            name: "ms-resource:appDisplayName".to_owned(),
11708            local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
11709            installed_version: "1.0".to_owned(),
11710            publisher: None,
11711            scope: Some("Machine".to_owned()),
11712            installer_category: Some("msi".to_owned()),
11713            install_location: None,
11714            package_family_names: Vec::new(),
11715            product_codes: Vec::new(),
11716            upgrade_codes: Vec::new(),
11717            correlated: Some(SearchMatch {
11718                source_name: "winget".to_owned(),
11719                source_kind: SourceKind::PreIndexed,
11720                id: "Some.Package".to_owned(),
11721                name: "Should Not Apply".to_owned(),
11722                moniker: None,
11723                version: Some("1.0".to_owned()),
11724                channel: None,
11725                match_criteria: None,
11726            }),
11727            installed_version_canonical: false,
11728            correlated_requires_explicit_upgrade: false,
11729            correlated_lacks_compatible_installer: false,
11730        };
11731        apply_msix_resource_string_name_fix(&mut package);
11732        assert_eq!(package.name, "ms-resource:appDisplayName");
11733    }
11734
11735    #[test]
11736    fn msix_resource_string_fix_skips_resolved_names() {
11737        // The vast majority of MSIX entries have already-resolved names
11738        // (`Microsoft Teams`, `Notepad++`, etc.). The fix must not touch
11739        // those — `meta.name` and the installed name may legitimately
11740        // differ (`Microsoft Teams` vs `Microsoft Teams (work or school)`).
11741        let mut package = InstalledPackage {
11742            name: "Microsoft Teams".to_owned(),
11743            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
11744            installed_version: "25290.205.4069.4894".to_owned(),
11745            publisher: None,
11746            scope: Some("User".to_owned()),
11747            installer_category: Some("msix".to_owned()),
11748            install_location: None,
11749            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
11750            product_codes: Vec::new(),
11751            upgrade_codes: Vec::new(),
11752            correlated: Some(SearchMatch {
11753                source_name: "winget".to_owned(),
11754                source_kind: SourceKind::PreIndexed,
11755                id: "Microsoft.Teams".to_owned(),
11756                name: "Microsoft Teams Catalog Name".to_owned(),
11757                moniker: None,
11758                version: Some("26106.1906.4665.7308".to_owned()),
11759                channel: None,
11760                match_criteria: Some("PackageFamilyName".to_owned()),
11761            }),
11762            installed_version_canonical: false,
11763            correlated_requires_explicit_upgrade: false,
11764            correlated_lacks_compatible_installer: false,
11765        };
11766        apply_msix_resource_string_name_fix(&mut package);
11767        assert_eq!(package.name, "Microsoft Teams");
11768    }
11769
11770    #[cfg(windows)]
11771    #[test]
11772    fn unflip_packed_guid_reverses_msi_installer_packing() {
11773        // The MSI Installer hive packs GUIDs by char-reversing each of the 11
11774        // chunks (8/4/4/2*8). This is the encoding used inside
11775        // `HKLM\SOFTWARE\Classes\Installer\UpgradeCodes\<key>` subkey names
11776        // and their value names. Verified against the user's installed
11777        // Node.js ProductCode `{9292CBD9-...}` whose packed form (from the
11778        // live registry) is `9DBC2929593B4D2488740C8E00C4F652`.
11779        assert_eq!(
11780            unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
11781            Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
11782        );
11783        // And the reverse case — Node.js UpgradeCode `{47c07a3a-...}` packed
11784        // form `A3A70C74FE2431248AD5F8A59570C782`.
11785        assert_eq!(
11786            unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
11787            Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
11788        );
11789        // Reject non-32-char or non-hex inputs.
11790        assert!(unflip_packed_guid("nothex").is_none());
11791        assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); // 31 chars
11792        assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
11793    }
11794
11795    fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
11796        InstalledPackage {
11797            name: format!("{id} install"),
11798            local_id: format!(r"ARP\Machine\X64\{id}"),
11799            installed_version: installed_version.to_owned(),
11800            publisher: None,
11801            scope: Some("Machine".to_owned()),
11802            installer_category: Some("msi".to_owned()),
11803            install_location: None,
11804            package_family_names: Vec::new(),
11805            product_codes: Vec::new(),
11806            upgrade_codes: Vec::new(),
11807            correlated: Some(SearchMatch {
11808                source_name: "winget".to_owned(),
11809                source_kind: SourceKind::PreIndexed,
11810                id: id.to_owned(),
11811                name: id.to_owned(),
11812                moniker: None,
11813                version: Some("99.0.0".to_owned()),
11814                channel: None,
11815                match_criteria: None,
11816            }),
11817            installed_version_canonical: canonical,
11818            correlated_requires_explicit_upgrade: false,
11819            correlated_lacks_compatible_installer: false,
11820        }
11821    }
11822
11823    #[test]
11824    fn dedupe_keeps_canonical_row_over_raw_arp_row() {
11825        // Two installed entries correlate to the same catalog id. One had its
11826        // ARP version remapped to a catalog Version via aMiV/aMaV (canonical);
11827        // the other did not (e.g. VS-installed .NET SDK whose ARP
11828        // `40.10.18029` doesn't fit any bucket). Without the canonical
11829        // preference, `compare_version` says `40.x.x.x > 10.0.108` so the
11830        // wrong row wins and the upgrade silently disappears.
11831        let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
11832        let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
11833
11834        let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
11835        assert_eq!(result.len(), 1);
11836        assert_eq!(result[0].installed_version, "10.0.108");
11837        assert!(result[0].installed_version_canonical);
11838    }
11839
11840    #[test]
11841    fn dedupe_keeps_highest_among_canonical_rows() {
11842        let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
11843        let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
11844
11845        let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
11846        assert_eq!(result.len(), 1);
11847        assert_eq!(result[0].installed_version, "1.7.9");
11848    }
11849
11850    #[test]
11851    fn dedupe_leaves_uncorrelated_rows_alone() {
11852        // Uncorrelated entries don't reach upgrade output anyway (the
11853        // upgrade filter requires correlation), but the dedupe pass must not
11854        // drop them — `pinget list` could share this code path in the future.
11855        let uncorrelated = InstalledPackage {
11856            name: "Foo".to_owned(),
11857            local_id: r"ARP\Machine\X64\Foo".to_owned(),
11858            installed_version: "1.0".to_owned(),
11859            publisher: None,
11860            scope: Some("Machine".to_owned()),
11861            installer_category: Some("exe".to_owned()),
11862            install_location: None,
11863            package_family_names: Vec::new(),
11864            product_codes: Vec::new(),
11865            upgrade_codes: Vec::new(),
11866            correlated: None,
11867            installed_version_canonical: false,
11868            correlated_requires_explicit_upgrade: false,
11869            correlated_lacks_compatible_installer: false,
11870        };
11871        let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
11872        assert_eq!(result.len(), 1);
11873    }
11874}