Skip to main content

pinget_core/
lib.rs

1use std::cmp::Ordering;
2use std::collections::{BTreeMap, BTreeSet, HashSet};
3use std::fmt::{Display, Formatter};
4use std::fs;
5use std::io::{Cursor, Read};
6#[cfg(windows)]
7use std::mem::{MaybeUninit, size_of};
8#[cfg(windows)]
9use std::os::windows::ffi::OsStringExt;
10use std::path::{Path, PathBuf};
11use std::sync::RwLock;
12
13use anyhow::{Context, Result, anyhow, bail};
14use chrono::{DateTime, Duration, Utc};
15use reqwest::blocking::{Client, Response};
16use rusqlite::types::{Value as SqlValue, ValueRef};
17use rusqlite::{Connection, OpenFlags, Row as SqlRow, params_from_iter};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map as JsonMap, Value as JsonValue};
20use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
21use sha2::{Digest, Sha256};
22#[cfg(windows)]
23use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
24#[cfg(windows)]
25use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
26#[cfg(windows)]
27use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
28#[cfg(windows)]
29use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
30#[cfg(windows)]
31use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
32#[cfg(windows)]
33use winreg::{RegKey, enums::*};
34use zip::ZipArchive;
35
36const DEFAULT_MARKET: &str = "US";
37const DEFAULT_MAX_RESULTS: usize = 50;
38const LIST_LOOKUP_MAX_RESULTS: usize = 500;
39const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
40const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
41#[cfg(windows)]
42const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
43#[cfg(windows)]
44const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
45const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
46    "Installed package discovery is not supported on this platform; returning no installed packages.";
47const INSTALL_UNSUPPORTED_WARNING: &str =
48    "Installing packages is not supported on this platform; no changes were made.";
49const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
50const REPAIR_REINSTALL_WARNING: &str =
51    "Pinget repair currently re-runs the package install flow for the selected package.";
52const UNINSTALL_UNSUPPORTED_WARNING: &str =
53    "Uninstalling packages is not supported on this platform; no changes were made.";
54#[cfg(windows)]
55const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
56const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
57    "LocalManifestFiles",
58    "BypassCertificatePinningForMicrosoftStore",
59    "InstallerHashOverride",
60    "LocalArchiveMalwareScanOverride",
61    "ProxyCommandLineOptions",
62];
63const REST_SUPPORTED_CONTRACTS: &[&str] = &[
64    "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",
65];
66
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
68pub enum SourceKind {
69    #[serde(rename = "preIndexed", alias = "PreIndexed")]
70    PreIndexed,
71    #[serde(rename = "rest", alias = "Rest")]
72    Rest,
73}
74
75impl Display for SourceKind {
76    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
77        match self {
78            SourceKind::PreIndexed => f.write_str("preindexed"),
79            SourceKind::Rest => f.write_str("rest"),
80        }
81    }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct SourceRecord {
86    pub name: String,
87    pub kind: SourceKind,
88    pub arg: String,
89    pub identifier: String,
90    #[serde(default = "default_source_trust_level")]
91    pub trust_level: String,
92    #[serde(default)]
93    pub explicit: bool,
94    #[serde(default)]
95    pub priority: i32,
96    #[serde(default)]
97    pub last_update: Option<DateTime<Utc>>,
98    #[serde(default)]
99    pub source_version: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103struct SourceStore {
104    sources: Vec<SourceRecord>,
105}
106
107impl Default for SourceStore {
108    fn default() -> Self {
109        Self {
110            sources: vec![
111                SourceRecord {
112                    name: "winget".to_owned(),
113                    kind: SourceKind::PreIndexed,
114                    arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
115                    identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
116                    trust_level: "Trusted".to_owned(),
117                    explicit: false,
118                    priority: 0,
119                    last_update: None,
120                    source_version: None,
121                },
122                SourceRecord {
123                    name: "msstore".to_owned(),
124                    kind: SourceKind::Rest,
125                    arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
126                    identifier: "StoreEdgeFD".to_owned(),
127                    trust_level: "Trusted".to_owned(),
128                    explicit: false,
129                    priority: 0,
130                    last_update: None,
131                    source_version: None,
132                },
133            ],
134        }
135    }
136}
137
138/// Options for embedding the WinGet core library in another application.
139#[derive(Debug, Clone)]
140pub struct RepositoryOptions {
141    pub app_root: PathBuf,
142    pub user_agent: String,
143}
144
145impl RepositoryOptions {
146    /// Creates library options that keep all persistent state under the supplied root.
147    pub fn new(app_root: impl Into<PathBuf>) -> Self {
148        Self {
149            app_root: app_root.into(),
150            user_agent: DEFAULT_USER_AGENT.to_owned(),
151        }
152    }
153
154    /// Uses the default per-user app-data root that the CLI also uses.
155    pub fn for_current_user() -> Result<Self> {
156        Ok(Self::new(default_app_root()?))
157    }
158
159    /// Overrides the HTTP user-agent sent to source endpoints.
160    #[must_use]
161    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
162        self.user_agent = user_agent.into();
163        self
164    }
165}
166
167#[derive(Debug, Clone, Default)]
168pub struct PackageQuery {
169    pub query: Option<String>,
170    pub id: Option<String>,
171    pub name: Option<String>,
172    pub moniker: Option<String>,
173    pub tag: Option<String>,
174    pub command: Option<String>,
175    pub source: Option<String>,
176    pub count: Option<usize>,
177    pub exact: bool,
178    pub version: Option<String>,
179    pub channel: Option<String>,
180    pub locale: Option<String>,
181    pub installer_type: Option<String>,
182    pub installer_architecture: Option<String>,
183    pub platform: Option<String>,
184    pub os_version: Option<String>,
185    pub install_scope: Option<String>,
186}
187
188#[derive(Debug, Clone, Default)]
189pub struct ListQuery {
190    pub query: Option<String>,
191    pub id: Option<String>,
192    pub name: Option<String>,
193    pub moniker: Option<String>,
194    pub tag: Option<String>,
195    pub command: Option<String>,
196    pub product_code: Option<String>,
197    pub version: Option<String>,
198    pub source: Option<String>,
199    pub count: Option<usize>,
200    pub exact: bool,
201    pub install_scope: Option<String>,
202    pub upgrade_only: bool,
203    pub include_unknown: bool,
204    pub include_pinned: bool,
205}
206
207#[derive(Debug, Clone, serde::Serialize)]
208pub struct SearchMatch {
209    pub source_name: String,
210    pub source_kind: SourceKind,
211    pub id: String,
212    pub name: String,
213    pub moniker: Option<String>,
214    pub version: Option<String>,
215    pub channel: Option<String>,
216    pub match_criteria: Option<String>,
217}
218
219#[derive(Debug, Clone, serde::Serialize)]
220pub struct SearchResponse {
221    pub matches: Vec<SearchMatch>,
222    pub warnings: Vec<String>,
223    pub truncated: bool,
224}
225
226#[derive(Debug, Clone, serde::Serialize)]
227pub struct ListMatch {
228    pub name: String,
229    pub id: String,
230    pub local_id: String,
231    pub installed_version: String,
232    pub available_version: Option<String>,
233    pub source_name: Option<String>,
234    pub publisher: Option<String>,
235    pub scope: Option<String>,
236    pub installer_category: Option<String>,
237    pub install_location: Option<String>,
238    pub package_family_names: Vec<String>,
239    pub product_codes: Vec<String>,
240    pub upgrade_codes: Vec<String>,
241}
242
243#[derive(Debug, Clone, serde::Serialize)]
244pub struct ListResponse {
245    pub matches: Vec<ListMatch>,
246    pub warnings: Vec<String>,
247    pub truncated: bool,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
251pub struct VersionKey {
252    pub version: String,
253    pub channel: String,
254}
255
256#[derive(Debug, Clone, serde::Serialize)]
257pub struct Manifest {
258    pub id: String,
259    pub name: String,
260    pub version: String,
261    pub channel: String,
262    pub publisher: Option<String>,
263    pub description: Option<String>,
264    pub moniker: Option<String>,
265    pub package_url: Option<String>,
266    pub publisher_url: Option<String>,
267    pub publisher_support_url: Option<String>,
268    pub license: Option<String>,
269    pub license_url: Option<String>,
270    pub privacy_url: Option<String>,
271    pub author: Option<String>,
272    pub copyright: Option<String>,
273    pub copyright_url: Option<String>,
274    pub release_notes: Option<String>,
275    pub release_notes_url: Option<String>,
276    pub tags: Vec<String>,
277    pub agreements: Vec<PackageAgreement>,
278    pub package_dependencies: Vec<String>,
279    pub documentation: Vec<Documentation>,
280    pub installers: Vec<Installer>,
281}
282
283#[derive(Debug, Clone, serde::Serialize)]
284pub struct Documentation {
285    pub label: Option<String>,
286    pub url: String,
287}
288
289#[derive(Debug, Clone, serde::Serialize)]
290pub struct PackageAgreement {
291    pub label: Option<String>,
292    pub text: Option<String>,
293    pub url: Option<String>,
294}
295
296#[derive(Debug, Clone, serde::Serialize)]
297pub struct Installer {
298    pub architecture: Option<String>,
299    pub installer_type: Option<String>,
300    pub url: Option<String>,
301    pub sha256: Option<String>,
302    pub product_code: Option<String>,
303    pub locale: Option<String>,
304    pub scope: Option<String>,
305    pub release_date: Option<String>,
306    pub package_family_name: Option<String>,
307    pub upgrade_code: Option<String>,
308    pub platforms: Vec<String>,
309    pub minimum_os_version: Option<String>,
310    pub switches: InstallerSwitches,
311    pub commands: Vec<String>,
312    pub package_dependencies: Vec<String>,
313}
314
315#[derive(Debug, Clone, Default, serde::Serialize)]
316pub struct InstallerSwitches {
317    pub silent: Option<String>,
318    pub silent_with_progress: Option<String>,
319    pub interactive: Option<String>,
320    pub custom: Option<String>,
321    pub log: Option<String>,
322    pub install_location: Option<String>,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum InstallerMode {
327    Interactive,
328    SilentWithProgress,
329    Silent,
330}
331
332#[derive(Debug, Clone)]
333pub struct InstallRequest {
334    pub query: PackageQuery,
335    pub manifest_path: Option<PathBuf>,
336    pub mode: InstallerMode,
337    pub log_path: Option<PathBuf>,
338    pub custom: Option<String>,
339    pub override_args: Option<String>,
340    pub install_location: Option<String>,
341    pub skip_dependencies: bool,
342    pub dependencies_only: bool,
343    pub accept_package_agreements: bool,
344    pub force: bool,
345    pub rename: Option<String>,
346    pub uninstall_previous: bool,
347    pub ignore_security_hash: bool,
348    pub dependency_source: Option<String>,
349    pub no_upgrade: bool,
350}
351
352impl InstallRequest {
353    pub fn new(query: PackageQuery) -> Self {
354        Self {
355            query,
356            manifest_path: None,
357            mode: InstallerMode::SilentWithProgress,
358            log_path: None,
359            custom: None,
360            override_args: None,
361            install_location: None,
362            skip_dependencies: false,
363            dependencies_only: false,
364            accept_package_agreements: false,
365            force: false,
366            rename: None,
367            uninstall_previous: false,
368            ignore_security_hash: false,
369            dependency_source: None,
370            no_upgrade: false,
371        }
372    }
373}
374
375#[derive(Debug, Clone)]
376pub struct UninstallRequest {
377    pub query: PackageQuery,
378    pub manifest_path: Option<PathBuf>,
379    pub product_code: Option<String>,
380    pub mode: InstallerMode,
381    pub all_versions: bool,
382    pub force: bool,
383    pub purge: bool,
384    pub preserve: bool,
385    pub log_path: Option<PathBuf>,
386}
387
388impl UninstallRequest {
389    pub fn new(query: PackageQuery) -> Self {
390        Self {
391            query,
392            manifest_path: None,
393            product_code: None,
394            mode: InstallerMode::SilentWithProgress,
395            all_versions: false,
396            force: false,
397            purge: false,
398            preserve: false,
399            log_path: None,
400        }
401    }
402}
403
404#[derive(Debug, Clone)]
405pub struct RepairRequest {
406    pub query: PackageQuery,
407    pub manifest_path: Option<PathBuf>,
408    pub product_code: Option<String>,
409    pub mode: InstallerMode,
410    pub log_path: Option<PathBuf>,
411    pub accept_package_agreements: bool,
412    pub force: bool,
413    pub ignore_security_hash: bool,
414}
415
416impl RepairRequest {
417    pub fn new(query: PackageQuery) -> Self {
418        Self {
419            query,
420            manifest_path: None,
421            product_code: None,
422            mode: InstallerMode::SilentWithProgress,
423            log_path: None,
424            accept_package_agreements: false,
425            force: false,
426            ignore_security_hash: false,
427        }
428    }
429}
430
431#[derive(Debug, Clone, serde::Serialize)]
432pub struct ShowResult {
433    pub package: SearchMatch,
434    pub manifest: Manifest,
435    pub selected_installer: Option<Installer>,
436    pub cached_files: Vec<PathBuf>,
437    pub warnings: Vec<String>,
438    #[serde(skip_serializing)]
439    manifest_documents: JsonValue,
440}
441
442impl ShowResult {
443    pub fn structured_document(&self) -> JsonValue {
444        collapse_structured_document(&self.manifest_documents)
445    }
446}
447
448#[derive(Debug, Clone, serde::Serialize)]
449pub struct VersionsResult {
450    pub package: SearchMatch,
451    pub versions: Vec<VersionKey>,
452    pub warnings: Vec<String>,
453}
454
455#[derive(Debug, Clone, serde::Serialize)]
456pub struct CacheWarmResult {
457    pub package: SearchMatch,
458    pub cached_files: Vec<PathBuf>,
459    pub warnings: Vec<String>,
460}
461
462#[derive(Debug, Clone, serde::Serialize)]
463pub struct SourceUpdateResult {
464    pub name: String,
465    pub kind: SourceKind,
466    pub detail: String,
467}
468
469#[derive(Debug, Clone, serde::Serialize)]
470pub struct PinRecord {
471    pub package_id: String,
472    pub version: String,
473    pub source_id: String,
474    pub pin_type: PinType,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
478pub enum PinType {
479    Pinning,
480    Blocking,
481    Gating,
482}
483
484impl Display for PinType {
485    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
486        match self {
487            PinType::Pinning => f.write_str("Pinning"),
488            PinType::Blocking => f.write_str("Blocking"),
489            PinType::Gating => f.write_str("Gating"),
490        }
491    }
492}
493
494/// Result of an install/uninstall/upgrade operation
495#[derive(Debug, Clone, serde::Serialize)]
496pub struct InstallResult {
497    pub package_id: String,
498    pub version: String,
499    pub installer_path: PathBuf,
500    pub installer_type: String,
501    pub exit_code: i32,
502    pub success: bool,
503    pub no_op: bool,
504    pub warnings: Vec<String>,
505}
506
507impl InstallerSwitches {
508    fn with_fallback(&self, fallback: &Self) -> Self {
509        Self {
510            silent: self.silent.clone().or_else(|| fallback.silent.clone()),
511            silent_with_progress: self
512                .silent_with_progress
513                .clone()
514                .or_else(|| fallback.silent_with_progress.clone()),
515            interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
516            custom: self.custom.clone().or_else(|| fallback.custom.clone()),
517            log: self.log.clone().or_else(|| fallback.log.clone()),
518            install_location: self
519                .install_location
520                .clone()
521                .or_else(|| fallback.install_location.clone()),
522        }
523    }
524}
525
526fn installed_package_discovery_supported() -> bool {
527    cfg!(windows)
528}
529
530fn package_actions_supported() -> bool {
531    cfg!(windows)
532}
533
534fn unsupported_action_result(
535    package_id: impl Into<String>,
536    version: impl Into<String>,
537    installer_type: impl Into<String>,
538    warning: &str,
539) -> InstallResult {
540    InstallResult {
541        package_id: package_id.into(),
542        version: version.into(),
543        installer_path: PathBuf::new(),
544        installer_type: installer_type.into(),
545        exit_code: 0,
546        success: true,
547        no_op: true,
548        warnings: vec![warning.to_owned()],
549    }
550}
551
552#[derive(Debug, Clone)]
553struct InstalledPackage {
554    name: String,
555    local_id: String,
556    installed_version: String,
557    publisher: Option<String>,
558    scope: Option<String>,
559    installer_category: Option<String>,
560    install_location: Option<String>,
561    package_family_names: Vec<String>,
562    product_codes: Vec<String>,
563    upgrade_codes: Vec<String>,
564    correlated: Option<SearchMatch>,
565}
566
567#[derive(Debug, Clone)]
568struct LocatedMatch {
569    display: SearchMatch,
570    source_index: usize,
571    locator: MatchLocator,
572}
573
574#[derive(Debug)]
575struct SearchSourceMatches {
576    matches: Vec<LocatedMatch>,
577    truncated: bool,
578}
579
580#[derive(Debug, Clone)]
581enum MatchLocator {
582    PreIndexedV1 {
583        package_rowid: i64,
584    },
585    PreIndexedV2 {
586        package_rowid: i64,
587        package_hash: String,
588    },
589    Rest {
590        package_id: String,
591        versions: Vec<VersionKey>,
592    },
593}
594
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596enum SearchSemantics {
597    Many,
598    Single,
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize)]
602struct RestInfoCache {
603    expires_at: DateTime<Utc>,
604    value: RestInformation,
605}
606
607#[derive(Debug, Clone, Default, Serialize, Deserialize)]
608struct RestInformation {
609    #[serde(rename = "SourceIdentifier")]
610    source_identifier: String,
611    #[serde(rename = "ServerSupportedVersions", default)]
612    server_supported_versions: Vec<String>,
613    #[serde(rename = "RequiredPackageMatchFields", default)]
614    required_package_match_fields: Vec<String>,
615    #[serde(rename = "UnsupportedPackageMatchFields", default)]
616    unsupported_package_match_fields: Vec<String>,
617    #[serde(rename = "RequiredQueryParameters", default)]
618    required_query_parameters: Vec<String>,
619    #[serde(rename = "UnsupportedQueryParameters", default)]
620    unsupported_query_parameters: Vec<String>,
621}
622
623#[derive(Debug, Clone, Deserialize)]
624struct PackageVersionDataDocument {
625    #[serde(rename = "vD", default)]
626    versions: Vec<PackageVersionDataEntry>,
627}
628
629#[derive(Debug, Clone, Deserialize)]
630struct PackageVersionDataEntry {
631    #[serde(rename = "v")]
632    version: String,
633    #[serde(rename = "rP")]
634    manifest_relative_path: String,
635    #[serde(rename = "s256H")]
636    manifest_hash: String,
637}
638
639pub struct Repository {
640    app_root: PathBuf,
641    client: Client,
642    store: SourceStore,
643    use_system_winget_sources: bool,
644}
645
646impl Repository {
647    /// Opens the repository with the default per-user CLI storage layout.
648    pub fn open() -> Result<Self> {
649        Self::open_with_options(RepositoryOptions::for_current_user()?)
650    }
651
652    /// Opens the repository with explicit hosting options for library consumers.
653    pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
654        ensure_app_dirs(&options.app_root)?;
655        let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
656        let store = load_store(&options.app_root)?;
657        let client = Client::builder()
658            .user_agent(&options.user_agent)
659            .build()
660            .context("failed to build HTTP client")?;
661        Ok(Self {
662            app_root: options.app_root,
663            client,
664            store,
665            use_system_winget_sources,
666        })
667    }
668
669    pub fn app_root(&self) -> &Path {
670        &self.app_root
671    }
672
673    pub fn list_sources(&self) -> Vec<SourceRecord> {
674        self.store.sources.clone()
675    }
676
677    pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
678        self.add_source_with_metadata(name, arg, kind, None, false, 0)
679    }
680
681    pub fn add_source_with_metadata(
682        &mut self,
683        name: &str,
684        arg: &str,
685        kind: SourceKind,
686        trust_level: Option<&str>,
687        explicit: bool,
688        priority: i32,
689    ) -> Result<()> {
690        if self.use_system_winget_sources {
691            system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
692            self.refresh_system_winget_sources()?;
693            return Ok(());
694        }
695
696        if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
697            bail!("A source with name '{name}' already exists.");
698        }
699        if self.store.sources.iter().any(|s| s.arg == arg) {
700            bail!("A source with argument '{arg}' already exists.");
701        }
702        let identifier = name.to_owned();
703        self.store.sources.push(SourceRecord {
704            name: name.to_owned(),
705            kind,
706            arg: arg.to_owned(),
707            identifier,
708            trust_level: normalize_source_trust_level(trust_level)?,
709            explicit,
710            priority,
711            last_update: None,
712            source_version: None,
713        });
714        self.save_store()?;
715        Ok(())
716    }
717
718    pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
719        if self.use_system_winget_sources {
720            bail!("Editing system WinGet sources is not supported by winget source commands.");
721        }
722
723        let source = self
724            .store
725            .sources
726            .iter_mut()
727            .find(|source| source.name.eq_ignore_ascii_case(name))
728            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
729
730        if let Some(explicit) = explicit {
731            source.explicit = explicit;
732        }
733
734        if trust_level.is_some() {
735            source.trust_level = normalize_source_trust_level(trust_level)?;
736        }
737
738        self.save_store()?;
739        Ok(())
740    }
741
742    pub fn remove_source(&mut self, name: &str) -> Result<()> {
743        if self.use_system_winget_sources {
744            system_winget_remove_source(name)?;
745            self.refresh_system_winget_sources()?;
746            return Ok(());
747        }
748
749        let idx = self
750            .store
751            .sources
752            .iter()
753            .position(|s| s.name.eq_ignore_ascii_case(name))
754            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
755        let source = self.store.sources.remove(idx);
756        // Clean up cached state for this source
757        let state_dir = self.source_state_dir(&source);
758        if state_dir.exists() {
759            let _ = fs::remove_dir_all(&state_dir);
760        }
761        self.save_store()?;
762        Ok(())
763    }
764
765    pub fn reset_source(&mut self, name: &str) -> Result<()> {
766        if self.use_system_winget_sources {
767            system_winget_reset_source(name)?;
768            self.refresh_system_winget_sources()?;
769            return Ok(());
770        }
771
772        let idx = self
773            .store
774            .sources
775            .iter()
776            .position(|source| source.name.eq_ignore_ascii_case(name))
777            .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
778
779        let source = self.store.sources[idx].clone();
780        let state_dir = self.source_state_dir(&source);
781        if state_dir.exists() {
782            let _ = fs::remove_dir_all(&state_dir);
783        }
784
785        if let Some(default_source) = SourceStore::default()
786            .sources
787            .into_iter()
788            .find(|source| source.name.eq_ignore_ascii_case(name))
789        {
790            self.store.sources[idx] = default_source;
791        } else {
792            self.store.sources[idx].last_update = None;
793            self.store.sources[idx].source_version = None;
794        }
795
796        self.save_store()?;
797        Ok(())
798    }
799
800    pub fn reset_sources(&mut self) -> Result<()> {
801        if self.use_system_winget_sources {
802            system_winget_reset_sources()?;
803            self.refresh_system_winget_sources()?;
804            return Ok(());
805        }
806
807        // Remove cached state for all current sources
808        for source in &self.store.sources {
809            let state_dir = self.source_state_dir(source);
810            if state_dir.exists() {
811                let _ = fs::remove_dir_all(&state_dir);
812            }
813        }
814        self.store = SourceStore::default();
815        self.save_store()?;
816        Ok(())
817    }
818
819    pub fn supported_admin_settings() -> &'static [&'static str] {
820        SUPPORTED_ADMIN_SETTINGS
821    }
822
823    pub fn get_user_settings(&self) -> Result<JsonValue> {
824        Ok(JsonValue::Object(load_json_object(&user_settings_path(
825            &self.app_root,
826        ))?))
827    }
828
829    pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
830        let update = match user_settings {
831            JsonValue::Object(object) => object.clone(),
832            _ => bail!("user settings must be a JSON object"),
833        };
834
835        let effective = if merge {
836            merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
837        } else {
838            update
839        };
840        save_json_object(user_settings_path(&self.app_root), &effective)?;
841        Ok(JsonValue::Object(effective))
842    }
843
844    pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
845        let current = self.get_user_settings()?;
846        Ok(if ignore_not_set {
847            json_contains(&current, expected)
848        } else {
849            current == *expected
850        })
851    }
852
853    pub fn get_admin_settings(&self) -> Result<JsonValue> {
854        let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
855        for name in SUPPORTED_ADMIN_SETTINGS {
856            settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
857        }
858
859        Ok(JsonValue::Object(settings))
860    }
861
862    pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
863        let normalized = normalize_admin_setting_name(name)?;
864        let mut settings = match self.get_admin_settings()? {
865            JsonValue::Object(settings) => settings,
866            _ => JsonMap::new(),
867        };
868        settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
869        save_json_object(admin_settings_path(&self.app_root), &settings)
870    }
871
872    pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
873        if !reset_all && name.is_none() {
874            bail!("resetting admin settings requires a setting name or reset-all");
875        }
876
877        let mut settings = match self.get_admin_settings()? {
878            JsonValue::Object(settings) => settings,
879            _ => JsonMap::new(),
880        };
881
882        if reset_all {
883            for setting_name in SUPPORTED_ADMIN_SETTINGS {
884                settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
885            }
886        } else {
887            settings.insert(
888                normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
889                JsonValue::Bool(false),
890            );
891        }
892
893        save_json_object(admin_settings_path(&self.app_root), &settings)
894    }
895
896    pub fn ensure_settings_files(&self) -> Result<()> {
897        let user_settings = user_settings_path(&self.app_root);
898        if !user_settings.exists() {
899            save_json_object(user_settings, &JsonMap::new())?;
900        }
901
902        let admin_settings = admin_settings_path(&self.app_root);
903        if !admin_settings.exists() {
904            let mut values = JsonMap::new();
905            for name in SUPPORTED_ADMIN_SETTINGS {
906                values.insert((*name).to_owned(), JsonValue::Bool(false));
907            }
908            save_json_object(admin_settings, &values)?;
909        }
910
911        Ok(())
912    }
913
914    pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
915        if self.use_system_winget_sources {
916            return self.update_system_winget_sources(source_name);
917        }
918
919        let indexes = self.resolve_source_indexes(source_name)?;
920        let mut results = Vec::new();
921
922        for index in indexes {
923            let detail = match self.store.sources[index].kind {
924                SourceKind::PreIndexed => self.update_preindexed(index)?,
925                SourceKind::Rest => self.update_rest(index)?,
926            };
927
928            results.push(SourceUpdateResult {
929                name: self.store.sources[index].name.clone(),
930                kind: self.store.sources[index].kind,
931                detail,
932            });
933        }
934
935        self.save_store()?;
936        Ok(results)
937    }
938
939    pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
940        let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
941        Ok(SearchResponse {
942            matches: matches.into_iter().map(|item| item.display).collect(),
943            warnings,
944            truncated,
945        })
946    }
947
948    pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
949        let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
950        let mut structured_documents = Vec::with_capacity(matches.len());
951        for located in matches {
952            let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
953            structured_documents.push(manifest_documents);
954        }
955
956        Ok(collapse_structured_documents(&structured_documents))
957    }
958
959    pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
960        if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
961            bail!("--include-unknown and --include-pinned require --upgrade-available");
962        }
963        if query.source.is_some()
964            && query.query.is_none()
965            && query.id.is_none()
966            && query.name.is_none()
967            && query.moniker.is_none()
968            && query.tag.is_none()
969            && query.command.is_none()
970        {
971            bail!("list --source currently requires a query or explicit filter");
972        }
973
974        let has_filter = list_query_needs_available_lookup(query);
975        let needs_available = has_filter || query.upgrade_only;
976
977        let mut warnings = Vec::new();
978        if !installed_package_discovery_supported() {
979            warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
980        }
981        let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
982
983        if needs_available && has_filter {
984            // Filtered lookup: search sources with the user's query
985            let available_query = package_query_from_list_query(query);
986            let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
987            warnings.extend(source_warnings);
988            let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
989            for package in &mut installed {
990                package.correlated =
991                    correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
992            }
993        } else if needs_available {
994            // Unfiltered upgrade: look up each installed package by its correlation names
995            warnings.extend(self.correlate_all_installed(&mut installed)?);
996        }
997
998        let mut matches = installed
999            .into_iter()
1000            .filter(|package| {
1001                list_package_matches(package, query)
1002                    && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1003            })
1004            .collect::<Vec<_>>();
1005
1006        matches.sort_by(|left, right| {
1007            list_sort_weight(left)
1008                .cmp(&list_sort_weight(right))
1009                .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1010                .then_with(|| left.local_id.cmp(&right.local_id))
1011        });
1012
1013        let pins = if query.upgrade_only {
1014            self.list_pins(query.source.as_deref())?
1015        } else {
1016            Vec::new()
1017        };
1018        let mut list_matches = matches
1019            .into_iter()
1020            .map(list_match_from_installed)
1021            .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1022            .collect::<Vec<_>>();
1023        let truncated = if let Some(limit) = query.count {
1024            let was_truncated = list_matches.len() > limit;
1025            list_matches.truncate(limit);
1026            was_truncated
1027        } else {
1028            false
1029        };
1030
1031        Ok(ListResponse {
1032            matches: list_matches,
1033            warnings,
1034            truncated,
1035        })
1036    }
1037
1038    /// For unfiltered upgrade/list, search the entire available index and correlate
1039    /// against all installed packages.
1040    fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1041        let all_query = PackageQuery {
1042            query: None,
1043            id: None,
1044            name: None,
1045            moniker: None,
1046            tag: None,
1047            command: None,
1048            source: None,
1049            count: Some(100_000), // fetch the entire index
1050            exact: false,
1051            version: None,
1052            channel: None,
1053            locale: None,
1054            installer_type: None,
1055            installer_architecture: None,
1056            platform: None,
1057            os_version: None,
1058            install_scope: None,
1059        };
1060        let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
1061        let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1062
1063        for package in installed.iter_mut() {
1064            package.correlated = correlate_installed_package(package, &candidates, true);
1065        }
1066
1067        Ok(warnings)
1068    }
1069
1070    pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1071        let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1072        let versions = self.versions_for_match(&located, query)?;
1073        Ok(VersionsResult {
1074            package: located.display,
1075            versions,
1076            warnings,
1077        })
1078    }
1079
1080    pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1081        let (located, warnings) = self.find_single_match(query)?;
1082        let versions = self.versions_for_match(&located, query)?;
1083        Ok(VersionsResult {
1084            package: located.display,
1085            versions,
1086            warnings,
1087        })
1088    }
1089
1090    pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1091        let (located, warnings) = self.find_single_match(query)?;
1092        let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1093        let selected_installer = select_installer(&manifest.installers, query);
1094
1095        Ok(ShowResult {
1096            package: located.display,
1097            manifest,
1098            selected_installer,
1099            cached_files,
1100            warnings,
1101            manifest_documents,
1102        })
1103    }
1104
1105    pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1106        let (located, warnings) = self.find_single_match(query)?;
1107        let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1108
1109        Ok(CacheWarmResult {
1110            package: located.display,
1111            cached_files,
1112            warnings,
1113        })
1114    }
1115
1116    // ── Pin management ──
1117
1118    pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
1119        let db_path = pins_db_path(&self.app_root);
1120        if !db_path.exists() {
1121            return Ok(Vec::new());
1122        }
1123        let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
1124        let pin_type_column = match resolve_pin_type_column(&conn)? {
1125            Some(column) => column,
1126            None => return Ok(Vec::new()),
1127        };
1128        let sql = if source_id.is_some() {
1129            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
1130        } else {
1131            format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
1132        };
1133        let mut stmt = conn.prepare(&sql)?;
1134        let rows = if let Some(source_id) = source_id {
1135            stmt.query_map([source_id], |row| {
1136                let pin_type_int: i64 = row.get(3)?;
1137                Ok(PinRecord {
1138                    package_id: row.get(0)?,
1139                    version: row.get(1)?,
1140                    source_id: row.get(2)?,
1141                    pin_type: decode_pin_type(pin_type_int),
1142                })
1143            })?
1144            .filter_map(|r| r.ok())
1145            .collect()
1146        } else {
1147            stmt.query_map([], |row| {
1148                let pin_type_int: i64 = row.get(3)?;
1149                Ok(PinRecord {
1150                    package_id: row.get(0)?,
1151                    version: row.get(1)?,
1152                    source_id: row.get(2)?,
1153                    pin_type: decode_pin_type(pin_type_int),
1154                })
1155            })?
1156            .filter_map(|r| r.ok())
1157            .collect()
1158        };
1159        Ok(rows)
1160    }
1161
1162    pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
1163        let db_path = pins_db_path(&self.app_root);
1164        let conn = Connection::open(&db_path)?;
1165        conn.execute_batch(
1166            "CREATE TABLE IF NOT EXISTS pin (
1167                package_id TEXT NOT NULL,
1168                version TEXT NOT NULL DEFAULT '*',
1169                source_id TEXT NOT NULL DEFAULT '',
1170                type INTEGER NOT NULL DEFAULT 0,
1171                PRIMARY KEY (package_id, source_id)
1172            )",
1173        )?;
1174        let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
1175        let type_int: i64 = match pin_type {
1176            PinType::Pinning => 2,
1177            PinType::Blocking => 4,
1178            PinType::Gating => 3,
1179        };
1180        conn.execute(
1181            &format!(
1182                "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
1183            ),
1184            rusqlite::params![package_id, version, source_id, type_int],
1185        )?;
1186        Ok(())
1187    }
1188
1189    pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
1190        let db_path = pins_db_path(&self.app_root);
1191        if !db_path.exists() {
1192            return Ok(false);
1193        }
1194        let conn = Connection::open(&db_path)?;
1195        let count = if let Some(source_id) = source_id {
1196            conn.execute(
1197                "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
1198                rusqlite::params![package_id, source_id],
1199            )?
1200        } else {
1201            conn.execute("DELETE FROM pin WHERE package_id = ?1", rusqlite::params![package_id])?
1202        };
1203        Ok(count > 0)
1204    }
1205
1206    pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
1207        let db_path = pins_db_path(&self.app_root);
1208        if !db_path.exists() {
1209            return Ok(());
1210        }
1211
1212        if let Some(source_id) = source_id {
1213            let conn = Connection::open(&db_path)?;
1214            conn.execute("DELETE FROM pin WHERE source_id = ?1", rusqlite::params![source_id])?;
1215        } else {
1216            fs::remove_file(&db_path)?;
1217        }
1218        Ok(())
1219    }
1220
1221    fn save_store(&self) -> Result<()> {
1222        save_store(&self.app_root, &self.store)
1223    }
1224
1225    fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
1226        source_state_dir(&self.app_root, source)
1227    }
1228
1229    // ── Install / download ──
1230
1231    pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<(Manifest, PathBuf)> {
1232        self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
1233    }
1234
1235    pub fn download_installer_for_request(
1236        &mut self,
1237        request: &InstallRequest,
1238        download_dir: &Path,
1239    ) -> Result<(Manifest, PathBuf)> {
1240        let manifest = self.resolve_manifest_for_install(request)?;
1241        let installer = select_installer(&manifest.installers, &request.query)
1242            .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
1243        let url = installer
1244            .url
1245            .as_deref()
1246            .ok_or_else(|| anyhow!("Installer has no URL"))?;
1247
1248        fs::create_dir_all(download_dir)?;
1249        let filename = request.rename.as_deref().unwrap_or_else(|| {
1250            url.rsplit('/')
1251                .next()
1252                .unwrap_or("installer")
1253                .split('?')
1254                .next()
1255                .unwrap_or("installer")
1256        });
1257        let dest = download_dir.join(filename);
1258
1259        let response = self.client.get(url).send().context("failed to download installer")?;
1260        if !response.status().is_success() {
1261            bail!("Download failed: HTTP {}", response.status());
1262        }
1263        let bytes = response.bytes()?;
1264        fs::write(&dest, &bytes)?;
1265
1266        if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
1267            let _ = fs::remove_file(&dest);
1268            return Err(error);
1269        }
1270
1271        Ok((manifest, dest))
1272    }
1273
1274    pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1275        let mut request = InstallRequest::new(query.clone());
1276        request.mode = if silent {
1277            InstallerMode::Silent
1278        } else {
1279            InstallerMode::SilentWithProgress
1280        };
1281        self.install_request(&request)
1282    }
1283
1284    pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
1285        let mut request = InstallRequest::new(query.clone());
1286        request.mode = mode;
1287        self.install_request(&request)
1288    }
1289
1290    pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
1291        let manifest = self.resolve_manifest_for_install(request)?;
1292        if !package_actions_supported() {
1293            let installer_type = select_installer(&manifest.installers, &request.query)
1294                .and_then(|installer| installer.installer_type)
1295                .unwrap_or_else(|| "install".to_owned())
1296                .to_lowercase();
1297            return Ok(unsupported_action_result(
1298                manifest.id.clone(),
1299                manifest.version.clone(),
1300                installer_type,
1301                INSTALL_UNSUPPORTED_WARNING,
1302            ));
1303        }
1304
1305        let existing_match = self.find_installed_package_for_install(request, &manifest)?;
1306        if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
1307            return Ok(no_op_result);
1308        }
1309
1310        Self::ensure_package_agreements_accepted(&manifest, request)?;
1311        self.install_dependencies(&manifest, request, &mut HashSet::new())?;
1312
1313        if request.dependencies_only {
1314            return Ok(InstallResult {
1315                package_id: manifest.id.clone(),
1316                version: manifest.version.clone(),
1317                installer_path: PathBuf::new(),
1318                installer_type: "dependencies".to_owned(),
1319                exit_code: 0,
1320                success: true,
1321                no_op: false,
1322                warnings: Vec::new(),
1323            });
1324        }
1325
1326        let installer = select_installer(&manifest.installers, &request.query)
1327            .ok_or_else(|| anyhow!("No applicable installer found"))?;
1328
1329        if request.uninstall_previous {
1330            let mut uninstall_request = UninstallRequest::new(PackageQuery {
1331                id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1332                query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
1333                source: request.query.source.clone(),
1334                exact: true,
1335                version: request.query.version.clone(),
1336                ..PackageQuery::default()
1337            });
1338            uninstall_request.product_code = installer.product_code.clone();
1339            uninstall_request.mode = InstallerMode::Silent;
1340            uninstall_request.all_versions = true;
1341            uninstall_request.force = true;
1342            let _ = self.uninstall_request(&uninstall_request);
1343        }
1344
1345        let temp_dir = std::env::temp_dir().join("pinget-install");
1346        let (_, installer_path) = self.download_installer_for_request(request, &temp_dir)?;
1347
1348        let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
1349
1350        let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
1351
1352        Ok(InstallResult {
1353            package_id: manifest.id.clone(),
1354            version: manifest.version.clone(),
1355            installer_path,
1356            installer_type,
1357            exit_code,
1358            success: exit_code == 0,
1359            no_op: false,
1360            warnings: Vec::new(),
1361        })
1362    }
1363
1364    fn find_installed_package_for_install(
1365        &mut self,
1366        request: &InstallRequest,
1367        manifest: &Manifest,
1368    ) -> Result<Option<ListMatch>> {
1369        let installed_matches = self.list(&ListQuery {
1370            query: request.query.query.clone(),
1371            id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1372            name: request.query.name.clone(),
1373            moniker: request.query.moniker.clone(),
1374            tag: None,
1375            command: None,
1376            product_code: None,
1377            version: None,
1378            source: request.query.source.clone(),
1379            count: Some(100),
1380            exact: request.query.exact || !manifest.id.trim().is_empty(),
1381            install_scope: request.query.install_scope.clone(),
1382            upgrade_only: false,
1383            include_unknown: false,
1384            include_pinned: false,
1385        })?;
1386
1387        Ok(installed_matches.matches.into_iter().find(|candidate| {
1388            candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
1389        }))
1390    }
1391
1392    fn create_install_no_op_result(
1393        request: &InstallRequest,
1394        manifest: &Manifest,
1395        existing_match: Option<&ListMatch>,
1396    ) -> Option<InstallResult> {
1397        let existing_match = existing_match?;
1398
1399        if request.no_upgrade {
1400            return Some(InstallResult {
1401                package_id: manifest.id.clone(),
1402                version: existing_match.installed_version.clone(),
1403                installer_path: PathBuf::new(),
1404                installer_type: "install".to_owned(),
1405                exit_code: 0,
1406                success: true,
1407                no_op: true,
1408                warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
1409            });
1410        }
1411
1412        if !request.force
1413            && !existing_match.installed_version.trim().is_empty()
1414            && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
1415        {
1416            return Some(InstallResult {
1417                package_id: manifest.id.clone(),
1418                version: existing_match.installed_version.clone(),
1419                installer_path: PathBuf::new(),
1420                installer_type: "install".to_owned(),
1421                exit_code: 0,
1422                success: true,
1423                no_op: true,
1424                warnings: vec![
1425                    "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
1426                ],
1427            });
1428        }
1429
1430        None
1431    }
1432
1433    pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
1434        if request.manifest_path.is_none() && !package_actions_supported() {
1435            let package_id = request
1436                .query
1437                .id
1438                .clone()
1439                .or_else(|| request.query.name.clone())
1440                .or_else(|| request.query.moniker.clone())
1441                .or_else(|| request.query.query.clone())
1442                .or_else(|| request.product_code.clone())
1443                .unwrap_or_else(|| "repair".to_owned());
1444            return Ok(unsupported_action_result(
1445                package_id,
1446                request.query.version.clone().unwrap_or_default(),
1447                "repair".to_owned(),
1448                REPAIR_UNSUPPORTED_WARNING,
1449            ));
1450        }
1451
1452        let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
1453        let installed_match = if request.manifest_path.is_some() {
1454            None
1455        } else {
1456            let installed = self.list(&Self::create_repair_list_query(request))?;
1457            warnings.extend(installed.warnings);
1458            if installed.matches.is_empty() {
1459                bail!("No installed package matched the supplied repair query.");
1460            }
1461            if installed.matches.len() > 1 {
1462                bail!("Multiple installed packages matched the supplied repair query.");
1463            }
1464            installed.matches.into_iter().next()
1465        };
1466
1467        let mut result =
1468            self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
1469        warnings.extend(result.warnings);
1470        result.warnings = warnings;
1471        Ok(result)
1472    }
1473
1474    pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1475        let mut request = UninstallRequest::new(query.clone());
1476        request.mode = if silent {
1477            InstallerMode::Silent
1478        } else {
1479            InstallerMode::SilentWithProgress
1480        };
1481        self.uninstall_request(&request)
1482    }
1483
1484    pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
1485        if !package_actions_supported() {
1486            let (package_id, version) = if let Some(path) = &request.manifest_path {
1487                let manifest = Self::load_manifest_from_path(path)?;
1488                (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
1489            } else {
1490                (
1491                    request
1492                        .query
1493                        .id
1494                        .clone()
1495                        .or_else(|| request.query.query.clone())
1496                        .or_else(|| request.query.name.clone())
1497                        .or_else(|| request.query.moniker.clone())
1498                        .or_else(|| request.product_code.clone())
1499                        .unwrap_or_else(|| "unknown".to_owned()),
1500                    request.query.version.clone().unwrap_or_default(),
1501                )
1502            };
1503
1504            return Ok(unsupported_action_result(
1505                package_id,
1506                version,
1507                "uninstall",
1508                UNINSTALL_UNSUPPORTED_WARNING,
1509            ));
1510        }
1511
1512        let matches = self.resolve_uninstall_matches(request)?;
1513        let mut exit_code = 0;
1514
1515        for installed in &matches {
1516            exit_code = uninstall_package(installed, request)?;
1517            if exit_code != 0 && !request.all_versions {
1518                break;
1519            }
1520        }
1521
1522        let installed = matches
1523            .first()
1524            .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
1525
1526        Ok(InstallResult {
1527            package_id: installed.id.clone(),
1528            version: installed.installed_version.clone(),
1529            installer_path: PathBuf::new(),
1530            installer_type: "uninstall".to_owned(),
1531            exit_code,
1532            success: exit_code == 0,
1533            no_op: false,
1534            warnings: Vec::new(),
1535        })
1536    }
1537
1538    fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
1539        if let Some(path) = &request.manifest_path {
1540            return Self::load_manifest_from_path(path);
1541        }
1542
1543        let (located, _warnings) = self.find_single_match(&request.query)?;
1544        let (manifest, _, _cached_files) = self.manifest_for_match(&located, &request.query)?;
1545        Ok(manifest)
1546    }
1547
1548    fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
1549        if !request.accept_package_agreements && !manifest.agreements.is_empty() {
1550            bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
1551        }
1552        Ok(())
1553    }
1554
1555    fn install_dependencies(
1556        &mut self,
1557        manifest: &Manifest,
1558        request: &InstallRequest,
1559        visited: &mut HashSet<String>,
1560    ) -> Result<()> {
1561        if request.skip_dependencies {
1562            return Ok(());
1563        }
1564
1565        let dependency_ids = manifest
1566            .package_dependencies
1567            .iter()
1568            .chain(
1569                manifest
1570                    .installers
1571                    .iter()
1572                    .flat_map(|installer| installer.package_dependencies.iter()),
1573            )
1574            .filter(|value| !value.trim().is_empty())
1575            .cloned()
1576            .collect::<BTreeSet<_>>();
1577
1578        for dependency_id in dependency_ids {
1579            if !visited.insert(dependency_id.clone()) {
1580                continue;
1581            }
1582
1583            let dependency_request = Self::create_dependency_install_request(dependency_id, request);
1584            self.install_request(&dependency_request)?;
1585        }
1586
1587        Ok(())
1588    }
1589
1590    fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
1591        let mut dependency_request = InstallRequest::new(PackageQuery {
1592            id: Some(dependency_id),
1593            source: request
1594                .dependency_source
1595                .clone()
1596                .or_else(|| request.query.source.clone()),
1597            exact: true,
1598            ..PackageQuery::default()
1599        });
1600        dependency_request.mode = match request.mode {
1601            InstallerMode::Interactive => InstallerMode::Interactive,
1602            InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
1603        };
1604        dependency_request.accept_package_agreements = request.accept_package_agreements;
1605        dependency_request.no_upgrade = true;
1606        dependency_request.ignore_security_hash = request.ignore_security_hash;
1607        dependency_request.dependency_source = request.dependency_source.clone();
1608        dependency_request
1609    }
1610
1611    fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
1612        ListQuery {
1613            query: request.query.query.clone(),
1614            id: request.query.id.clone(),
1615            name: request.query.name.clone(),
1616            moniker: request.query.moniker.clone(),
1617            product_code: request.product_code.clone(),
1618            version: request.query.version.clone(),
1619            source: request.query.source.clone(),
1620            count: Some(100),
1621            exact: request.query.exact,
1622            install_scope: request.query.install_scope.clone(),
1623            ..ListQuery::default()
1624        }
1625    }
1626
1627    fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
1628        let mut query = request.query.clone();
1629        query.query = if installed_match.is_none() {
1630            request.query.query.clone()
1631        } else {
1632            None
1633        };
1634        query.id = installed_match
1635            .map(|item| item.id.clone())
1636            .or_else(|| request.query.id.clone());
1637        query.name = if installed_match.is_none() {
1638            request.query.name.clone()
1639        } else {
1640            None
1641        };
1642        query.moniker = if installed_match.is_none() {
1643            request.query.moniker.clone()
1644        } else {
1645            None
1646        };
1647        query.source = request
1648            .query
1649            .source
1650            .clone()
1651            .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
1652        query.exact = true;
1653        query.version = request
1654            .query
1655            .version
1656            .clone()
1657            .or_else(|| installed_match.map(|item| item.installed_version.clone()));
1658
1659        InstallRequest {
1660            query,
1661            manifest_path: request.manifest_path.clone(),
1662            mode: request.mode,
1663            log_path: request.log_path.clone(),
1664            custom: None,
1665            override_args: None,
1666            install_location: None,
1667            skip_dependencies: false,
1668            dependencies_only: false,
1669            accept_package_agreements: request.accept_package_agreements,
1670            force: true,
1671            rename: None,
1672            uninstall_previous: false,
1673            ignore_security_hash: request.ignore_security_hash,
1674            dependency_source: None,
1675            no_upgrade: false,
1676        }
1677    }
1678
1679    fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
1680        let mut effective_query = request.query.clone();
1681        let mut effective_product_code = request.product_code.clone();
1682
1683        if let Some(path) = &request.manifest_path {
1684            let manifest = Self::load_manifest_from_path(path)?;
1685            effective_query = PackageQuery {
1686                query: Some(manifest.id.clone()),
1687                id: Some(manifest.id.clone()),
1688                name: Some(manifest.name.clone()),
1689                exact: true,
1690                version: effective_query.version.clone().or(Some(manifest.version)),
1691                ..PackageQuery::default()
1692            };
1693            effective_product_code = effective_product_code.or_else(|| {
1694                manifest
1695                    .installers
1696                    .iter()
1697                    .find_map(|installer| installer.product_code.clone())
1698            });
1699        }
1700
1701        let list_query = ListQuery {
1702            query: effective_query.query.clone(),
1703            id: effective_query.id.clone(),
1704            name: effective_query.name.clone(),
1705            moniker: effective_query.moniker.clone(),
1706            tag: None,
1707            command: None,
1708            product_code: effective_product_code,
1709            version: effective_query.version.clone(),
1710            source: effective_query.source.clone(),
1711            count: if request.all_versions { None } else { Some(100) },
1712            exact: effective_query.exact,
1713            install_scope: effective_query.install_scope,
1714            upgrade_only: false,
1715            include_unknown: false,
1716            include_pinned: false,
1717        };
1718
1719        let list_result = self.list(&list_query)?;
1720        if list_result.matches.is_empty() {
1721            bail!("No installed package found matching the query");
1722        }
1723        if !request.all_versions && list_result.matches.len() > 1 && !request.force {
1724            bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
1725        }
1726
1727        Ok(if request.all_versions {
1728            list_result.matches
1729        } else {
1730            vec![list_result.matches[0].clone()]
1731        })
1732    }
1733
1734    fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
1735        let resolved = resolve_manifest_path(manifest_path)?;
1736        let bytes =
1737            fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
1738        parse_yaml_manifest(&bytes)
1739    }
1740
1741    fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
1742        self.find_single_match_with_semantics(query, SearchSemantics::Single)
1743    }
1744
1745    fn find_single_match_with_semantics(
1746        &mut self,
1747        query: &PackageQuery,
1748        semantics: SearchSemantics,
1749    ) -> Result<(LocatedMatch, Vec<String>)> {
1750        let (matches, warnings, _) = self.search_located(query, semantics)?;
1751
1752        if matches.is_empty() {
1753            bail!("no package matched the supplied query");
1754        }
1755
1756        if matches.len() > 1 {
1757            let choices = matches
1758                .iter()
1759                .take(10)
1760                .map(|item| {
1761                    format!(
1762                        "{} [{}] ({})",
1763                        item.display.name, item.display.id, item.display.source_name
1764                    )
1765                })
1766                .collect::<Vec<_>>()
1767                .join(", ");
1768            bail!("multiple packages matched: {choices}");
1769        }
1770
1771        Ok((matches.into_iter().next().expect("one match"), warnings))
1772    }
1773
1774    fn search_located(
1775        &mut self,
1776        query: &PackageQuery,
1777        semantics: SearchSemantics,
1778    ) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
1779        let indexes = self.resolve_source_indexes(query.source.as_deref())?;
1780        let mut matches = Vec::new();
1781        let mut warnings = Vec::new();
1782        let mut truncated = false;
1783
1784        for index in indexes {
1785            match self.search_source(index, query, semantics) {
1786                Ok(mut source_matches) => {
1787                    truncated |= source_matches.truncated;
1788                    matches.append(&mut source_matches.matches);
1789                }
1790                Err(_error) => warnings.push(format!(
1791                    "Failed when searching source; results will not be included: {}",
1792                    self.store.sources[index].name
1793                )),
1794            }
1795        }
1796
1797        if semantics == SearchSemantics::Many {
1798            matches.sort_by(|left, right| {
1799                search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
1800            });
1801            let limit = max_results(query);
1802            if matches.len() > limit {
1803                truncated = true;
1804                matches.truncate(limit);
1805            }
1806        }
1807
1808        Ok((matches, warnings, truncated))
1809    }
1810
1811    fn search_source(
1812        &mut self,
1813        source_index: usize,
1814        query: &PackageQuery,
1815        semantics: SearchSemantics,
1816    ) -> Result<SearchSourceMatches> {
1817        match self.store.sources[source_index].kind {
1818            SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
1819            SourceKind::Rest => self.search_rest(source_index, query, semantics),
1820        }
1821    }
1822
1823    fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
1824        match &located.locator {
1825            MatchLocator::PreIndexedV1 { package_rowid } => {
1826                let source = self.source_clone(located.source_index);
1827                let connection = self.open_preindexed_connection(located.source_index)?;
1828                let versions = query_v1_versions(&connection, *package_rowid)?;
1829                let mut keys = versions
1830                    .into_iter()
1831                    .map(|row| VersionKey {
1832                        version: row.version,
1833                        channel: row.channel,
1834                    })
1835                    .collect::<Vec<_>>();
1836                sort_versions_desc(&mut keys);
1837                if keys.is_empty() {
1838                    bail!("no versions found for {} in {}", located.display.id, source.name);
1839                }
1840                Ok(keys)
1841            }
1842            MatchLocator::PreIndexedV2 {
1843                package_rowid,
1844                package_hash,
1845            } => {
1846                let source = self.source_clone(located.source_index);
1847                let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
1848                let mut keys = entries
1849                    .into_iter()
1850                    .map(|entry| VersionKey {
1851                        version: entry.version,
1852                        channel: String::new(),
1853                    })
1854                    .collect::<Vec<_>>();
1855                sort_versions_desc(&mut keys);
1856                Ok(keys)
1857            }
1858            MatchLocator::Rest { versions, .. } => {
1859                let mut keys = versions.clone();
1860                sort_versions_desc(&mut keys);
1861                Ok(keys)
1862            }
1863        }
1864    }
1865
1866    fn manifest_for_match(
1867        &mut self,
1868        located: &LocatedMatch,
1869        query: &PackageQuery,
1870    ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
1871        match &located.locator {
1872            MatchLocator::PreIndexedV1 { package_rowid } => {
1873                let source = self.source_clone(located.source_index);
1874                let connection = self.open_preindexed_connection(located.source_index)?;
1875                let versions = query_v1_versions(&connection, *package_rowid)?;
1876                let selected = select_v1_version(&versions, query.version.as_deref(), query.channel.as_deref())?;
1877                let relative_path = resolve_v1_relative_path(&connection, selected.pathpart_id)?;
1878                let bytes =
1879                    self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
1880                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
1881                manifest.version = selected.version.clone();
1882                manifest.channel = selected.channel;
1883                Ok((manifest, manifest_documents, vec![bytes.path]))
1884            }
1885            MatchLocator::PreIndexedV2 {
1886                package_rowid,
1887                package_hash,
1888            } => {
1889                let source = self.source_clone(located.source_index);
1890                let (entries, version_data_file) =
1891                    self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
1892                let selected = select_v2_version(&entries, query.version.as_deref())?;
1893                let manifest_bytes = self.get_cached_source_file(
1894                    "V2_M",
1895                    &source,
1896                    &selected.manifest_relative_path,
1897                    Some(selected.manifest_hash.as_str()),
1898                )?;
1899                let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
1900                manifest.version = selected.version;
1901                Ok((
1902                    manifest,
1903                    manifest_documents,
1904                    vec![version_data_file, manifest_bytes.path],
1905                ))
1906            }
1907            MatchLocator::Rest { package_id, versions } => {
1908                let source = self.source_clone(located.source_index);
1909                let selected = select_rest_version(versions, query.version.as_deref(), query.channel.as_deref())?;
1910                let (bytes, cache_path) =
1911                    self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
1912                let (manifest, manifest_documents) =
1913                    parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
1914                Ok((manifest, manifest_documents, vec![cache_path]))
1915            }
1916        }
1917    }
1918
1919    fn search_preindexed(
1920        &mut self,
1921        source_index: usize,
1922        query: &PackageQuery,
1923        semantics: SearchSemantics,
1924    ) -> Result<SearchSourceMatches> {
1925        let connection = self.open_preindexed_connection(source_index)?;
1926        let source = self.source_clone(source_index);
1927
1928        match query_v2_matches(&connection, query, semantics) {
1929            Ok((rows, truncated)) => Ok(SearchSourceMatches {
1930                truncated,
1931                matches: rows
1932                    .into_iter()
1933                    .map(|row| LocatedMatch {
1934                        display: SearchMatch {
1935                            source_name: source.name.clone(),
1936                            source_kind: source.kind,
1937                            id: row.id.clone(),
1938                            name: row.name.clone(),
1939                            moniker: row.moniker.clone(),
1940                            version: Some(row.version.clone()),
1941                            channel: None,
1942                            match_criteria: row.match_criteria.clone(),
1943                        },
1944                        source_index,
1945                        locator: MatchLocator::PreIndexedV2 {
1946                            package_rowid: row.package_rowid,
1947                            package_hash: row.package_hash,
1948                        },
1949                    })
1950                    .collect(),
1951            }),
1952            Err(error) if can_fallback_to_v1(&error) => {
1953                let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
1954                let grouped = group_v1_rows(rows);
1955
1956                Ok(SearchSourceMatches {
1957                    truncated,
1958                    matches: grouped
1959                        .into_iter()
1960                        .map(|row| LocatedMatch {
1961                            display: SearchMatch {
1962                                source_name: source.name.clone(),
1963                                source_kind: source.kind,
1964                                id: row.id.clone(),
1965                                name: row.name.clone(),
1966                                moniker: row.moniker.clone(),
1967                                version: Some(row.version.clone()),
1968                                channel: Some(row.channel.clone()),
1969                                match_criteria: row.match_criteria.clone(),
1970                            },
1971                            source_index,
1972                            locator: MatchLocator::PreIndexedV1 {
1973                                package_rowid: row.package_rowid,
1974                            },
1975                        })
1976                        .collect(),
1977                })
1978            }
1979            Err(error) => Err(error),
1980        }
1981    }
1982
1983    fn search_rest(
1984        &mut self,
1985        source_index: usize,
1986        query: &PackageQuery,
1987        semantics: SearchSemantics,
1988    ) -> Result<SearchSourceMatches> {
1989        let source = self.source_clone(source_index);
1990        let info = self.load_rest_information(source_index)?;
1991        let contract = choose_contract(&info.server_supported_versions)
1992            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
1993
1994        let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
1995        let body = build_rest_search_body(query, &info, semantics)?;
1996        let response = self
1997            .client
1998            .post(url)
1999            .header("Version", contract)
2000            .json(&body)
2001            .send()
2002            .context("REST search request failed")?
2003            .error_for_status()
2004            .context("REST search request returned an error")?;
2005        let json = response
2006            .json::<JsonValue>()
2007            .context("failed to parse REST search response")?;
2008        let data = json
2009            .get("Data")
2010            .and_then(JsonValue::as_array)
2011            .cloned()
2012            .unwrap_or_default();
2013        let max_results = source_fetch_results(query, semantics);
2014
2015        let mut results = Vec::new();
2016        for item in data {
2017            let package_id = json_string(&item, "PackageIdentifier")
2018                .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
2019            let package_name =
2020                json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
2021            let mut versions = parse_rest_versions(&item)?;
2022            sort_versions_desc(&mut versions);
2023            let latest = versions
2024                .first()
2025                .cloned()
2026                .ok_or_else(|| anyhow!("REST search result had no versions"))?;
2027
2028            results.push(LocatedMatch {
2029                display: SearchMatch {
2030                    source_name: source.name.clone(),
2031                    source_kind: source.kind,
2032                    id: package_id.clone(),
2033                    name: package_name,
2034                    moniker: json_string(&item, "Moniker"),
2035                    version: Some(latest.version.clone()),
2036                    channel: if latest.channel.is_empty() {
2037                        None
2038                    } else {
2039                        Some(latest.channel.clone())
2040                    },
2041                    match_criteria: rest_match_criteria(&item, query, semantics),
2042                },
2043                source_index,
2044                locator: MatchLocator::Rest { package_id, versions },
2045            });
2046        }
2047
2048        Ok(SearchSourceMatches {
2049            truncated: results.len() >= max_results,
2050            matches: results,
2051        })
2052    }
2053
2054    fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
2055        let source = self.source_clone(source_index);
2056        let index_path = preindexed_index_path(&self.app_root, &source);
2057        if !index_path.exists() {
2058            let _ = self.update_preindexed(source_index)?;
2059            if !self.use_system_winget_sources {
2060                self.save_store()?;
2061            }
2062        }
2063
2064        Self::open_sqlite_connection(index_path).context("failed to open preindexed index")
2065    }
2066
2067    fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
2068        let path_text = path.to_string_lossy().into_owned();
2069        Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI)
2070            .with_context(|| format!("failed to open SQLite database at {path_text}"))
2071    }
2072
2073    fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
2074        let source = &mut self.store.sources[source_index];
2075        let state_dir = source_state_dir(&self.app_root, source);
2076        fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
2077
2078        let mut last_error = None;
2079        for candidate in PREINDEXED_CANDIDATES {
2080            let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
2081            match self.client.get(&url).send() {
2082                Ok(response) if response.status().is_success() => {
2083                    let header_version = response
2084                        .headers()
2085                        .get("x-ms-meta-sourceversion")
2086                        .and_then(|value| value.to_str().ok())
2087                        .map(str::to_owned);
2088                    let bytes = response.bytes().context("failed to read preindexed package bytes")?;
2089                    let payload = bytes.to_vec();
2090                    let index_bytes = extract_zip_member(&payload, "Public/index.db")
2091                        .context("preindexed package did not contain Public/index.db")?;
2092
2093                    fs::write(preindexed_package_path(&self.app_root, source), &payload)
2094                        .context("failed to persist source package")?;
2095                    fs::write(preindexed_index_path(&self.app_root, source), index_bytes)
2096                        .context("failed to persist source index")?;
2097                    source.last_update = Some(Utc::now());
2098                    source.source_version = header_version;
2099                    return Ok(format!("downloaded {}", candidate));
2100                }
2101                Ok(response) => {
2102                    last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
2103                }
2104                Err(error) => {
2105                    last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
2106                }
2107            }
2108        }
2109
2110        Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
2111    }
2112
2113    fn update_rest(&mut self, source_index: usize) -> Result<String> {
2114        let info = self.fetch_rest_information(source_index, true)?;
2115        let source = &mut self.store.sources[source_index];
2116        if !info.source_identifier.is_empty() {
2117            source.identifier = info.source_identifier.clone();
2118        }
2119        source.last_update = Some(Utc::now());
2120        source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
2121        Ok("refreshed information cache".to_owned())
2122    }
2123
2124    fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
2125        let source = self.source_clone(source_index);
2126        let cache_path = rest_information_cache_path(&self.app_root, &source);
2127
2128        if cache_path.exists() {
2129            let cache = serde_json::from_slice::<RestInfoCache>(
2130                &fs::read(&cache_path).context("failed to read REST information cache")?,
2131            )
2132            .context("failed to parse REST information cache")?;
2133            if cache.expires_at > Utc::now() {
2134                return Ok(cache.value);
2135            }
2136        }
2137
2138        self.fetch_rest_information(source_index, true)
2139    }
2140
2141    fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
2142        let source = self.source_clone(source_index);
2143        let url = format!("{}/information", source.arg.trim_end_matches('/'));
2144        let response = self
2145            .client
2146            .get(url)
2147            .send()
2148            .context("REST information request failed")?
2149            .error_for_status()
2150            .context("REST information request returned an error")?;
2151
2152        let max_age = cache_control_max_age(&response);
2153        let json = response
2154            .json::<JsonValue>()
2155            .context("failed to parse REST information response")?;
2156        let data = json
2157            .get("Data")
2158            .cloned()
2159            .ok_or_else(|| anyhow!("REST information response missing Data"))?;
2160        let info = serde_json::from_value::<RestInformation>(data)
2161            .context("failed to deserialize REST information payload")?;
2162
2163        if persist {
2164            let cache = RestInfoCache {
2165                expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
2166                value: info.clone(),
2167            };
2168            write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
2169        }
2170
2171        Ok(info)
2172    }
2173
2174    fn load_v2_version_data(
2175        &self,
2176        source: &SourceRecord,
2177        package_rowid: i64,
2178        package_hash: &str,
2179    ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
2180        let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
2181            .context("failed to reopen preindexed index for V2 version data")?;
2182        let package_hash = package_hash.to_ascii_lowercase();
2183        let package_id = query_optional_value(
2184            &connection,
2185            "SELECT id FROM packages WHERE rowid = ?1",
2186            vec![SqlValue::Integer(package_rowid)],
2187            |row| row_string(row, 0),
2188        )?
2189        .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
2190        let relative_path = package_version_data_relative_path(&package_id, &package_hash);
2191        let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
2192        let yaml = decompress_mszyml(&file.bytes)?;
2193        let document =
2194            serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
2195        Ok((document.versions, file.path))
2196    }
2197
2198    fn get_cached_source_file(
2199        &self,
2200        bucket: &str,
2201        source: &SourceRecord,
2202        relative_path: &str,
2203        expected_hash: Option<&str>,
2204    ) -> Result<CachedBytes> {
2205        let normalized_relative = relative_path.replace('\\', "/");
2206        let cache_path =
2207            temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
2208
2209        if cache_path.exists() {
2210            let cached = fs::read(&cache_path).context("failed to read cached source file")?;
2211            if hash_matches(expected_hash, &cached) {
2212                return Ok(CachedBytes {
2213                    path: cache_path,
2214                    bytes: cached,
2215                });
2216            }
2217        }
2218
2219        let url = format!(
2220            "{}/{}",
2221            source.arg.trim_end_matches('/'),
2222            normalized_relative.trim_start_matches('/')
2223        );
2224        let response = self
2225            .client
2226            .get(url)
2227            .send()
2228            .context("failed to fetch source file")?
2229            .error_for_status()
2230            .context("source file request returned an error")?;
2231        let bytes = response.bytes().context("failed to read source file body")?.to_vec();
2232
2233        if let Some(hash) = expected_hash {
2234            verify_hash(hash, &bytes)?;
2235        }
2236
2237        if let Some(parent) = cache_path.parent() {
2238            fs::create_dir_all(parent).context("failed to create cache directory")?;
2239        }
2240        fs::write(&cache_path, &bytes).context("failed to write cache file")?;
2241
2242        Ok(CachedBytes {
2243            path: cache_path,
2244            bytes,
2245        })
2246    }
2247
2248    fn get_or_fetch_rest_manifest(
2249        &mut self,
2250        source: &SourceRecord,
2251        package_id: &str,
2252        version: &str,
2253        channel: &str,
2254    ) -> Result<(Vec<u8>, PathBuf)> {
2255        let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
2256        if cache_path.exists() {
2257            return Ok((
2258                fs::read(&cache_path).context("failed to read cached REST manifest")?,
2259                cache_path,
2260            ));
2261        }
2262
2263        let info = self.load_rest_information_by_name(&source.name)?;
2264        let contract = choose_contract(&info.server_supported_versions)
2265            .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2266        let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
2267
2268        let mut params = vec![("Version", version.to_owned())];
2269        if !channel.is_empty() {
2270            params.push(("Channel", channel.to_owned()));
2271        }
2272        if info
2273            .required_query_parameters
2274            .iter()
2275            .any(|value| value.eq_ignore_ascii_case("Market"))
2276        {
2277            params.push(("Market", default_market()));
2278        }
2279
2280        let response = self
2281            .client
2282            .get(url)
2283            .header("Version", contract)
2284            .query(&params)
2285            .send()
2286            .context("REST manifest request failed")?
2287            .error_for_status()
2288            .context("REST manifest request returned an error")?;
2289        let bytes = response
2290            .bytes()
2291            .context("failed to read REST manifest response")?
2292            .to_vec();
2293
2294        if let Some(parent) = cache_path.parent() {
2295            fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
2296        }
2297        fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
2298
2299        Ok((bytes, cache_path))
2300    }
2301
2302    fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
2303        let index = self
2304            .store
2305            .sources
2306            .iter()
2307            .position(|source| source.name.eq_ignore_ascii_case(name))
2308            .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
2309        self.load_rest_information(index)
2310    }
2311
2312    fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
2313        if let Some(name) = source_name {
2314            let index = self
2315                .store
2316                .sources
2317                .iter()
2318                .position(|source| source.name.eq_ignore_ascii_case(name))
2319                .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
2320            return Ok(vec![index]);
2321        }
2322
2323        let mut indexes: Vec<_> = self
2324            .store
2325            .sources
2326            .iter()
2327            .enumerate()
2328            .filter(|(_, source)| !source.explicit)
2329            .collect();
2330        indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
2331            right_source
2332                .priority
2333                .cmp(&left_source.priority)
2334                .then_with(|| left_index.cmp(right_index))
2335        });
2336        Ok(indexes.into_iter().map(|(index, _)| index).collect())
2337    }
2338
2339    fn source_clone(&self, index: usize) -> SourceRecord {
2340        self.store.sources[index].clone()
2341    }
2342
2343    fn refresh_system_winget_sources(&mut self) -> Result<()> {
2344        if self.use_system_winget_sources {
2345            self.store = load_system_winget_source_store()?;
2346        }
2347        Ok(())
2348    }
2349
2350    fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
2351        self.refresh_system_winget_sources()?;
2352        let selected_names: Vec<_> = if source_name.is_none() {
2353            self.store.sources.iter().map(|source| source.name.clone()).collect()
2354        } else {
2355            self.resolve_source_indexes(source_name)?
2356                .into_iter()
2357                .map(|index| self.store.sources[index].name.clone())
2358                .collect()
2359        };
2360
2361        let detail = system_winget_update_sources(source_name)?;
2362        self.refresh_system_winget_sources()?;
2363
2364        Ok(selected_names
2365            .into_iter()
2366            .filter_map(|name| {
2367                self.store
2368                    .sources
2369                    .iter()
2370                    .find(|source| source.name.eq_ignore_ascii_case(&name))
2371                    .map(|source| SourceUpdateResult {
2372                        name: source.name.clone(),
2373                        kind: source.kind,
2374                        detail: detail.clone(),
2375                    })
2376            })
2377            .collect())
2378    }
2379}
2380
2381#[derive(Debug)]
2382struct CachedBytes {
2383    path: PathBuf,
2384    bytes: Vec<u8>,
2385}
2386
2387#[derive(Debug, Clone)]
2388struct V1SearchRow {
2389    manifest_rowid: i64,
2390    package_rowid: i64,
2391    version: String,
2392    channel: String,
2393    id: String,
2394    name: String,
2395    moniker: Option<String>,
2396    match_criteria: Option<String>,
2397}
2398
2399#[derive(Debug, Clone)]
2400struct V1VersionRow {
2401    version: String,
2402    channel: String,
2403    pathpart_id: i64,
2404    manifest_hash: Option<String>,
2405}
2406
2407#[derive(Debug, Clone)]
2408struct V2SearchRow {
2409    package_rowid: i64,
2410    package_hash: String,
2411    id: String,
2412    name: String,
2413    moniker: Option<String>,
2414    version: String,
2415    match_criteria: Option<String>,
2416}
2417
2418fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
2419    query.query.is_some()
2420        || query.id.is_some()
2421        || query.name.is_some()
2422        || query.moniker.is_some()
2423        || query.tag.is_some()
2424        || query.command.is_some()
2425        || query.source.is_some()
2426}
2427
2428fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
2429    PackageQuery {
2430        query: query.query.clone(),
2431        id: query.id.clone(),
2432        name: query.name.clone(),
2433        moniker: query.moniker.clone(),
2434        tag: query.tag.clone(),
2435        command: query.command.clone(),
2436        source: query.source.clone(),
2437        count: Some(LIST_LOOKUP_MAX_RESULTS),
2438        exact: query.exact,
2439        version: None,
2440        channel: None,
2441        locale: None,
2442        installer_type: None,
2443        installer_architecture: None,
2444        platform: None,
2445        os_version: None,
2446        install_scope: query.install_scope.clone(),
2447    }
2448}
2449
2450fn allow_loose_list_correlation(query: &ListQuery) -> bool {
2451    query.query.is_some() || query.id.is_some() || query.name.is_some()
2452}
2453
2454fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
2455    package
2456        .correlated
2457        .as_ref()
2458        .and_then(|candidate| candidate.version.as_deref())
2459        .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
2460}
2461
2462fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
2463    package.installed_version.eq_ignore_ascii_case("Unknown")
2464}
2465
2466fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
2467    installed_package_has_upgrade(package)
2468        || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
2469}
2470
2471fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
2472    let mut source_specific = None;
2473    let mut source_agnostic = None;
2474
2475    for pin in pins {
2476        if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
2477            continue;
2478        }
2479
2480        if pin.source_id.is_empty() {
2481            if source_agnostic.is_none() {
2482                source_agnostic = Some(pin);
2483            }
2484        } else if item
2485            .source_name
2486            .as_deref()
2487            .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
2488        {
2489            source_specific = Some(pin);
2490            break;
2491        }
2492    }
2493
2494    source_specific.or(source_agnostic)
2495}
2496
2497fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
2498    let Some(available_version) = item.available_version.as_deref() else {
2499        return false;
2500    };
2501    let Some(pin) = find_applicable_pin(item, pins) else {
2502        return false;
2503    };
2504
2505    match pin.pin_type {
2506        PinType::Blocking => true,
2507        PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
2508    }
2509}
2510
2511fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
2512    if pattern.trim().is_empty() || pattern == "*" {
2513        return true;
2514    }
2515
2516    if !pattern.contains('*') {
2517        return version.eq_ignore_ascii_case(pattern);
2518    }
2519
2520    if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
2521        let prefix = &pattern[..pattern.len() - 1];
2522        return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
2523    }
2524
2525    version.eq_ignore_ascii_case(pattern)
2526}
2527
2528fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
2529    let available_version = package.correlated.as_ref().and_then(|candidate| {
2530        candidate.version.as_ref().and_then(|candidate_version| {
2531            if installed_package_has_unknown_version(&package)
2532                || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
2533            {
2534                Some(candidate_version.clone())
2535            } else {
2536                None
2537            }
2538        })
2539    });
2540    let source_name = package
2541        .correlated
2542        .as_ref()
2543        .map(|candidate| candidate.source_name.clone())
2544        .filter(|value| !value.is_empty());
2545    let id = package
2546        .correlated
2547        .as_ref()
2548        .map(|candidate| candidate.id.clone())
2549        .unwrap_or_else(|| package.local_id.clone());
2550
2551    ListMatch {
2552        name: package.name,
2553        id,
2554        local_id: package.local_id,
2555        installed_version: package.installed_version,
2556        available_version,
2557        source_name,
2558        publisher: package.publisher,
2559        scope: package.scope,
2560        installer_category: package.installer_category,
2561        install_location: package.install_location,
2562        package_family_names: package.package_family_names,
2563        product_codes: package.product_codes,
2564        upgrade_codes: package.upgrade_codes,
2565    }
2566}
2567
2568fn list_sort_weight(package: &InstalledPackage) -> usize {
2569    if package.local_id.starts_with("ARP\\") {
2570        0
2571    } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
2572        1
2573    } else {
2574        2
2575    }
2576}
2577
2578fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
2579    let correlated = package.correlated.as_ref();
2580
2581    if let Some(value) = &query.id {
2582        let local_match = matches_text(&package.local_id, value, query.exact);
2583        let correlated_match = correlated
2584            .map(|candidate| matches_text(&candidate.id, value, query.exact))
2585            .unwrap_or(false);
2586        if !local_match && !correlated_match {
2587            return false;
2588        }
2589    }
2590
2591    if let Some(value) = &query.name
2592        && !matches_text(&package.name, value, query.exact)
2593    {
2594        return false;
2595    }
2596
2597    if let Some(value) = &query.product_code
2598        && !package
2599            .product_codes
2600            .iter()
2601            .any(|code| code.eq_ignore_ascii_case(value))
2602    {
2603        return false;
2604    }
2605
2606    if let Some(value) = &query.version
2607        && !package.installed_version.eq_ignore_ascii_case(value)
2608    {
2609        return false;
2610    }
2611
2612    if let Some(value) = &query.query {
2613        let local_match =
2614            matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
2615        let correlated_match = correlated
2616            .map(|candidate| {
2617                matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
2618            })
2619            .unwrap_or(false);
2620        if !local_match && !correlated_match {
2621            return false;
2622        }
2623    }
2624
2625    if let Some(source) = &query.source
2626        && correlated
2627            .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
2628            .unwrap_or(true)
2629    {
2630        return false;
2631    }
2632
2633    if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
2634        return false;
2635    }
2636
2637    true
2638}
2639
2640fn correlate_installed_package(
2641    package: &InstalledPackage,
2642    candidates: &[SearchMatch],
2643    allow_loose_name_match: bool,
2644) -> Option<SearchMatch> {
2645    if package.local_id.starts_with("MSIX\\") {
2646        return None;
2647    }
2648
2649    let installed_name = normalize_correlation_name(&package.name);
2650    let candidate_names = correlation_name_candidates(&package.name);
2651
2652    candidates
2653        .iter()
2654        .filter_map(|candidate| {
2655            let candidate_name = normalize_correlation_name(&candidate.name);
2656            let score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
2657                1000
2658            } else if candidate_names.iter().any(|name| {
2659                let normalized = normalize_correlation_name(name);
2660                normalized == candidate_name
2661            }) {
2662                900
2663            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
2664                700
2665            } else {
2666                0
2667            };
2668
2669            (score > 0).then_some((score, candidate.clone()))
2670        })
2671        .max_by_key(|(score, _)| *score)
2672        .map(|(_, candidate)| candidate)
2673}
2674
2675fn correlation_name_candidates(name: &str) -> Vec<String> {
2676    let mut candidates = Vec::new();
2677    candidates.push(name.trim().to_owned());
2678
2679    let mut trimmed = name.trim().to_owned();
2680    if let Some(index) = trimmed.find(" (") {
2681        trimmed.truncate(index);
2682    }
2683
2684    let mut words = Vec::new();
2685    for token in trimmed.split_whitespace() {
2686        let lower = token
2687            .trim_matches(|ch: char| ch == '(' || ch == ')')
2688            .to_ascii_lowercase();
2689        if !words.is_empty()
2690            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
2691        {
2692            break;
2693        }
2694        words.push(token);
2695    }
2696
2697    if !words.is_empty() {
2698        candidates.push(words.join(" "));
2699    }
2700
2701    candidates.sort();
2702    candidates.dedup();
2703    candidates
2704}
2705
2706fn normalize_correlation_name(value: &str) -> String {
2707    value
2708        .chars()
2709        .filter(|ch| ch.is_ascii_alphanumeric())
2710        .flat_map(|ch| ch.to_lowercase())
2711        .collect()
2712}
2713
2714#[cfg(windows)]
2715fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
2716    let mut packages = Vec::new();
2717    let mut seen = BTreeSet::new();
2718
2719    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
2720    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
2721
2722    if machine {
2723        collect_uninstall_view(
2724            &mut packages,
2725            &mut seen,
2726            RegKey::predef(HKEY_LOCAL_MACHINE),
2727            "Machine",
2728            "X64",
2729            KEY_READ | KEY_WOW64_64KEY,
2730        )?;
2731        collect_uninstall_view(
2732            &mut packages,
2733            &mut seen,
2734            RegKey::predef(HKEY_LOCAL_MACHINE),
2735            "Machine",
2736            "X86",
2737            KEY_READ | KEY_WOW64_32KEY,
2738        )?;
2739        collect_appmodel_packages(
2740            &mut packages,
2741            &mut seen,
2742            RegKey::predef(HKEY_LOCAL_MACHINE),
2743            "Machine",
2744            KEY_READ | KEY_WOW64_64KEY,
2745        )?;
2746    }
2747
2748    if user {
2749        collect_uninstall_view(
2750            &mut packages,
2751            &mut seen,
2752            RegKey::predef(HKEY_CURRENT_USER),
2753            "User",
2754            "X64",
2755            KEY_READ,
2756        )?;
2757        collect_appmodel_packages(
2758            &mut packages,
2759            &mut seen,
2760            RegKey::predef(HKEY_CURRENT_USER),
2761            "User",
2762            KEY_READ,
2763        )?;
2764    }
2765
2766    Ok(packages)
2767}
2768
2769#[cfg(not(windows))]
2770fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
2771    Ok(Vec::new())
2772}
2773
2774#[cfg(windows)]
2775fn collect_uninstall_view(
2776    packages: &mut Vec<InstalledPackage>,
2777    seen: &mut BTreeSet<String>,
2778    root: RegKey,
2779    scope: &str,
2780    arch: &str,
2781    flags: u32,
2782) -> Result<()> {
2783    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
2784
2785    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
2786        Ok(key) => key,
2787        Err(_) => return Ok(()),
2788    };
2789
2790    for key_name in uninstall.enum_keys().flatten() {
2791        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
2792            Ok(key) => key,
2793            Err(_) => continue,
2794        };
2795        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
2796        {
2797            continue;
2798        }
2799
2800        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
2801            continue;
2802        };
2803
2804        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
2805        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
2806        let publisher = read_reg_string(&subkey, "Publisher");
2807        let install_location = read_reg_string(&subkey, "InstallLocation");
2808        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
2809            .into_iter()
2810            .collect::<Vec<_>>();
2811        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
2812        if product_codes.is_empty() && looks_like_product_code(&key_name) {
2813            product_codes.push(key_name.to_ascii_lowercase());
2814        }
2815        let upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
2816        let installer_category =
2817            if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
2818                Some("msi".to_owned())
2819            } else if key_name.starts_with("MSIX\\") {
2820                Some("msix".to_owned())
2821            } else {
2822                Some("exe".to_owned())
2823            };
2824
2825        let dedupe_key = format!(
2826            "{}|{}|{}|{}",
2827            local_id,
2828            name.to_ascii_lowercase(),
2829            installed_version.to_ascii_lowercase(),
2830            publisher.clone().unwrap_or_default().to_ascii_lowercase()
2831        );
2832        if !seen.insert(dedupe_key) {
2833            continue;
2834        }
2835
2836        packages.push(InstalledPackage {
2837            name,
2838            local_id,
2839            installed_version,
2840            publisher,
2841            scope: Some(scope.to_owned()),
2842            installer_category,
2843            install_location,
2844            package_family_names,
2845            product_codes,
2846            upgrade_codes,
2847            correlated: None,
2848        });
2849    }
2850
2851    Ok(())
2852}
2853
2854#[cfg(windows)]
2855fn collect_appmodel_packages(
2856    packages: &mut Vec<InstalledPackage>,
2857    seen: &mut BTreeSet<String>,
2858    root: RegKey,
2859    scope: &str,
2860    flags: u32,
2861) -> Result<()> {
2862    const APPMODEL_PACKAGES_PATH: &str =
2863        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
2864
2865    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
2866        Ok(key) => key,
2867        Err(_) => return Ok(()),
2868    };
2869
2870    for key_name in appmodel.enum_keys().flatten() {
2871        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
2872            Ok(key) => key,
2873            Err(_) => continue,
2874        };
2875
2876        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
2877            continue;
2878        };
2879        let install_location = read_reg_string(&subkey, "PackageRootFolder");
2880        if install_location.as_deref().is_some_and(is_windows_system_path) {
2881            continue;
2882        }
2883
2884        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
2885            continue;
2886        };
2887
2888        let local_id = format!(r"MSIX\{key_name}");
2889        let dedupe_key = format!(
2890            "{}|{}|{}",
2891            local_id,
2892            name.to_ascii_lowercase(),
2893            metadata.version.to_ascii_lowercase()
2894        );
2895        if !seen.insert(dedupe_key) {
2896            continue;
2897        }
2898
2899        packages.push(InstalledPackage {
2900            name,
2901            local_id,
2902            installed_version: metadata.version,
2903            publisher: None,
2904            scope: Some(scope.to_owned()),
2905            installer_category: Some("msix".to_owned()),
2906            install_location,
2907            package_family_names: vec![metadata.family_name],
2908            product_codes: Vec::new(),
2909            upgrade_codes: Vec::new(),
2910            correlated: None,
2911        });
2912    }
2913
2914    Ok(())
2915}
2916
2917#[cfg(windows)]
2918struct ParsedMsixPackageFullName {
2919    version: String,
2920    family_name: String,
2921}
2922
2923#[cfg(windows)]
2924fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
2925    let segments = value.split('_').collect::<Vec<_>>();
2926    if segments.len() < 5 {
2927        return None;
2928    }
2929
2930    let name = segments[..segments.len() - 4].join("_");
2931    if name.is_empty() {
2932        return None;
2933    }
2934
2935    let version = segments[segments.len() - 4].trim();
2936    let resource_id = segments[segments.len() - 2].trim();
2937    let publisher_id = segments[segments.len() - 1].trim();
2938    if version.is_empty() || publisher_id.is_empty() {
2939        return None;
2940    }
2941
2942    let family_name = if resource_id.is_empty() {
2943        format!("{name}_{publisher_id}")
2944    } else {
2945        format!("{name}_{resource_id}_{publisher_id}")
2946    };
2947
2948    Some(ParsedMsixPackageFullName {
2949        version: version.to_owned(),
2950        family_name,
2951    })
2952}
2953
2954#[cfg(windows)]
2955fn is_windows_system_path(path: &str) -> bool {
2956    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
2957}
2958
2959#[cfg(windows)]
2960fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
2961    key.get_value::<String, _>(value_name)
2962        .ok()
2963        .map(|value| value.trim().to_owned())
2964        .filter(|value| !value.is_empty())
2965}
2966
2967#[cfg(windows)]
2968fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
2969    key.get_value::<u32, _>(value_name).ok()
2970}
2971
2972#[cfg(windows)]
2973fn looks_like_product_code(value: &str) -> bool {
2974    value.starts_with('{') && value.ends_with('}')
2975}
2976
2977fn ensure_app_dirs(app_root: &Path) -> Result<()> {
2978    fs::create_dir_all(app_root).context("failed to create app root directory")?;
2979    if uses_packaged_layout(app_root) {
2980        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
2981        let user_sources_path = packaged_user_sources_path(app_root)?;
2982        if let Some(parent) = user_sources_path.parent() {
2983            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
2984        }
2985    } else {
2986        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
2987    }
2988    Ok(())
2989}
2990
2991fn default_app_root() -> Result<PathBuf> {
2992    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
2993        return Ok(PathBuf::from(app_root));
2994    }
2995
2996    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
2997
2998    #[cfg(windows)]
2999    {
3000        if has_current_package_identity() {
3001            return Ok(local_app_data
3002                .join("Packages")
3003                .join(PACKAGED_FAMILY_NAME)
3004                .join("LocalState"));
3005        }
3006        Ok(local_app_data.join("Devolutions").join("Pinget"))
3007    }
3008
3009    #[cfg(not(windows))]
3010    {
3011        Ok(local_app_data.join("pinget"))
3012    }
3013}
3014
3015#[cfg(windows)]
3016fn has_current_package_identity() -> bool {
3017    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
3018    // process has no AppX package identity.
3019    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
3020
3021    let mut length: u32 = 0;
3022    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
3023    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
3024    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
3025    rc != APPMODEL_ERROR_NO_PACKAGE
3026}
3027
3028fn store_path(app_root: &Path) -> PathBuf {
3029    app_root.join("sources.json")
3030}
3031
3032fn user_settings_path(app_root: &Path) -> PathBuf {
3033    if uses_packaged_layout(app_root) {
3034        app_root.join("settings.json")
3035    } else {
3036        app_root.join("user-settings.json")
3037    }
3038}
3039
3040fn admin_settings_path(app_root: &Path) -> PathBuf {
3041    app_root.join("admin-settings.json")
3042}
3043
3044fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
3045    if uses_packaged_layout(app_root) {
3046        return app_root
3047            .join(packaged_source_type(source.kind))
3048            .join(sanitize_path_segment(&source.identifier));
3049    }
3050
3051    app_root.join("sources").join(sanitize_path_segment(&source.name))
3052}
3053
3054fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3055    source_state_dir(app_root, source).join("source.msix")
3056}
3057
3058fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3059    source_state_dir(app_root, source).join("index.db")
3060}
3061
3062fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3063    source_state_dir(app_root, source).join("rest-information.json")
3064}
3065
3066fn rest_manifest_cache_path_with_root(
3067    app_root: &Path,
3068    source: &SourceRecord,
3069    package_id: &str,
3070    version: &str,
3071    channel: &str,
3072) -> PathBuf {
3073    let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
3074    let digest = sha256_hex(key.as_bytes());
3075    cache_root(app_root)
3076        .join("REST_M")
3077        .join(sanitize_path_segment(&source.identifier))
3078        .join(format!("{digest}.json"))
3079}
3080
3081fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
3082    cache_root(app_root)
3083        .join(bucket)
3084        .join(sanitize_path_segment(identifier))
3085}
3086
3087fn load_store(app_root: &Path) -> Result<SourceStore> {
3088    if uses_packaged_layout(app_root)
3089        && let Some(store) = load_packaged_store(app_root)?
3090    {
3091        return Ok(store);
3092    }
3093
3094    let path = store_path(app_root);
3095    if !path.exists() {
3096        let store = SourceStore::default();
3097        save_store(app_root, &store)?;
3098        return Ok(store);
3099    }
3100
3101    let bytes = fs::read(path).context("failed to read source store")?;
3102    serde_json::from_slice(&bytes).context("failed to parse source store")
3103}
3104
3105fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
3106    if uses_system_winget_source_commands(app_root) {
3107        bail!("System WinGet source settings must be modified through winget.");
3108    }
3109
3110    if uses_packaged_layout(app_root) {
3111        return save_packaged_store(app_root, store);
3112    }
3113
3114    write_json(store_path(app_root), store)
3115}
3116
3117fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
3118    let user_sources_path = packaged_user_sources_path(app_root)?;
3119    let metadata_path = app_root.join("sources_metadata");
3120    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
3121    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
3122    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
3123}
3124
3125fn load_packaged_store_from_streams(
3126    user_sources_yaml: Option<&str>,
3127    metadata_yaml: Option<&str>,
3128) -> Result<Option<SourceStore>> {
3129    if is_secure_settings_stub(user_sources_yaml) {
3130        return load_system_winget_source_store().map(Some);
3131    }
3132
3133    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
3134}
3135
3136fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
3137    let user_sources_path = packaged_user_sources_path(app_root)?;
3138    if let Some(parent) = user_sources_path.parent() {
3139        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
3140    }
3141
3142    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
3143        .context("failed to write packaged user_sources stream")?;
3144    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
3145        .context("failed to write packaged source metadata")?;
3146    Ok(())
3147}
3148
3149fn uses_system_winget_source_commands(app_root: &Path) -> bool {
3150    if !uses_packaged_layout(app_root) {
3151        return false;
3152    }
3153
3154    #[cfg(windows)]
3155    {
3156        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
3157            return false;
3158        };
3159
3160        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
3161        is_secure_settings_stub(user_sources_yaml.as_deref())
3162    }
3163
3164    #[cfg(not(windows))]
3165    {
3166        false
3167    }
3168}
3169
3170fn is_secure_settings_stub(value: Option<&str>) -> bool {
3171    value
3172        .map(str::trim_start)
3173        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
3174}
3175
3176fn load_system_winget_source_store() -> Result<SourceStore> {
3177    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
3178    Ok(SourceStore {
3179        sources: parse_system_winget_source_export(&result.stdout)?,
3180    })
3181}
3182
3183fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
3184    run_checked_winget_source_command(
3185        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
3186        &format!("add WinGet source '{name}'"),
3187    )?;
3188    Ok(())
3189}
3190
3191fn system_winget_remove_source(name: &str) -> Result<()> {
3192    run_checked_winget_source_command(
3193        vec![
3194            "source".to_owned(),
3195            "remove".to_owned(),
3196            "--name".to_owned(),
3197            name.to_owned(),
3198            "--disable-interactivity".to_owned(),
3199        ],
3200        &format!("remove WinGet source '{name}'"),
3201    )?;
3202    Ok(())
3203}
3204
3205fn system_winget_reset_source(name: &str) -> Result<()> {
3206    run_checked_winget_source_command(
3207        vec![
3208            "source".to_owned(),
3209            "reset".to_owned(),
3210            "--name".to_owned(),
3211            name.to_owned(),
3212            "--force".to_owned(),
3213            "--disable-interactivity".to_owned(),
3214        ],
3215        &format!("reset WinGet source '{name}'"),
3216    )?;
3217    Ok(())
3218}
3219
3220fn system_winget_reset_sources() -> Result<()> {
3221    run_checked_winget_source_command(
3222        vec![
3223            "source".to_owned(),
3224            "reset".to_owned(),
3225            "--force".to_owned(),
3226            "--disable-interactivity".to_owned(),
3227        ],
3228        "reset WinGet sources",
3229    )?;
3230    Ok(())
3231}
3232
3233fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
3234    let mut args = vec!["source".to_owned(), "update".to_owned()];
3235    if let Some(name) = name {
3236        args.push("--name".to_owned());
3237        args.push(name.to_owned());
3238    }
3239
3240    args.push("--disable-interactivity".to_owned());
3241    let result = run_checked_winget_source_command(
3242        args,
3243        if name.is_some() {
3244            "update WinGet source"
3245        } else {
3246            "update WinGet sources"
3247        },
3248    )?;
3249    let detail = result.stdout.trim();
3250    Ok(if detail.is_empty() {
3251        "Done".to_owned()
3252    } else {
3253        detail.to_owned()
3254    })
3255}
3256
3257fn build_winget_source_export_arguments() -> Vec<String> {
3258    vec![
3259        "source".to_owned(),
3260        "export".to_owned(),
3261        "--disable-interactivity".to_owned(),
3262    ]
3263}
3264
3265fn build_winget_source_add_arguments(
3266    name: &str,
3267    arg: &str,
3268    kind: SourceKind,
3269    trust_level: &str,
3270    explicit: bool,
3271) -> Vec<String> {
3272    let mut args = vec![
3273        "source".to_owned(),
3274        "add".to_owned(),
3275        "--name".to_owned(),
3276        name.to_owned(),
3277        "--arg".to_owned(),
3278        arg.to_owned(),
3279        "--type".to_owned(),
3280        packaged_source_type(kind).to_owned(),
3281        "--disable-interactivity".to_owned(),
3282    ];
3283
3284    if !trust_level.trim().is_empty() {
3285        args.push("--trust-level".to_owned());
3286        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
3287            "trusted".to_owned()
3288        } else {
3289            "none".to_owned()
3290        });
3291    }
3292
3293    if explicit {
3294        args.push("--explicit".to_owned());
3295    }
3296
3297    args
3298}
3299
3300#[derive(Debug)]
3301struct WingetSourceCommandResult {
3302    exit_code: i32,
3303    stdout: String,
3304    stderr: String,
3305}
3306
3307type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
3308
3309static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
3310    RwLock::new(run_winget_source_command_process);
3311
3312fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
3313    let result = run_winget_source_command(&args)?;
3314    if result.exit_code == 0 {
3315        return Ok(result);
3316    }
3317
3318    let detail = [result.stderr.trim(), result.stdout.trim()]
3319        .into_iter()
3320        .filter(|value| !value.is_empty())
3321        .collect::<Vec<_>>()
3322        .join("\n");
3323    if detail.is_empty() {
3324        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
3325    }
3326
3327    bail!(
3328        "Failed to {action}; winget exited with code {}:\n{}",
3329        result.exit_code,
3330        detail
3331    );
3332}
3333
3334fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
3335    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
3336        .read()
3337        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
3338    runner(args)
3339}
3340
3341fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
3342    use std::process::Command;
3343
3344    let output = Command::new("winget")
3345        .args(args)
3346        .output()
3347        .context("failed to run winget source command")?;
3348
3349    Ok(WingetSourceCommandResult {
3350        exit_code: output.status.code().unwrap_or(-1),
3351        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3352        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3353    })
3354}
3355
3356fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
3357    let trimmed = output.trim();
3358    if trimmed.is_empty() {
3359        return Ok(Vec::new());
3360    }
3361
3362    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
3363        && let Some(sources) = parse_system_winget_source_export_value(&value)?
3364    {
3365        return Ok(order_sources(sources));
3366    }
3367
3368    let mut sources = Vec::new();
3369    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
3370        if line.is_empty() {
3371            continue;
3372        }
3373
3374        let value: JsonValue = serde_json::from_str(line)
3375            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
3376        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
3377            bail!("WinGet source export returned a non-source line: {line}");
3378        };
3379        sources.append(&mut parsed);
3380    }
3381
3382    Ok(order_sources(sources))
3383}
3384
3385fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
3386    match value {
3387        JsonValue::Array(items) => items
3388            .iter()
3389            .map(parse_system_winget_source_record)
3390            .collect::<Result<Vec<_>>>()
3391            .map(Some),
3392        JsonValue::Object(object) => {
3393            if let Some(JsonValue::Array(items)) = object.get("Sources") {
3394                items
3395                    .iter()
3396                    .map(parse_system_winget_source_record)
3397                    .collect::<Result<Vec<_>>>()
3398                    .map(Some)
3399            } else if object.contains_key("Name") {
3400                Ok(Some(vec![parse_system_winget_source_record(value)?]))
3401            } else {
3402                Ok(None)
3403            }
3404        }
3405        _ => Ok(None),
3406    }
3407}
3408
3409fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
3410    let object = value
3411        .as_object()
3412        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
3413    let name = required_json_string(object, "Name")?;
3414    let source_type = required_json_string(object, "Type")?;
3415    let arg = required_json_string(object, "Arg")?;
3416    let kind = parse_system_winget_source_kind(&source_type)?;
3417    let identifier = optional_json_string(object, "Identifier")
3418        .or_else(|| optional_json_string(object, "Data"))
3419        .unwrap_or_else(|| name.clone());
3420
3421    Ok(SourceRecord {
3422        name,
3423        kind,
3424        arg,
3425        identifier,
3426        trust_level: parse_system_winget_trust_level(object),
3427        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
3428        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
3429        last_update: None,
3430        source_version: None,
3431    })
3432}
3433
3434fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
3435    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
3436}
3437
3438fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
3439    match object.get(key)? {
3440        JsonValue::String(value) => Some(value.clone()),
3441        JsonValue::Number(value) => Some(value.to_string()),
3442        _ => None,
3443    }
3444}
3445
3446fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
3447    match object.get(key)? {
3448        JsonValue::Bool(value) => Some(*value),
3449        JsonValue::String(value) => value.parse().ok(),
3450        _ => None,
3451    }
3452}
3453
3454fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
3455    match object.get(key)? {
3456        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
3457        JsonValue::String(value) => value.parse().ok(),
3458        _ => None,
3459    }
3460}
3461
3462fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
3463    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
3464        Ok(SourceKind::PreIndexed)
3465    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
3466        Ok(SourceKind::Rest)
3467    } else {
3468        bail!("Unsupported WinGet source type '{value}'.")
3469    }
3470}
3471
3472fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
3473    let Some(value) = object.get("TrustLevel") else {
3474        return "None".to_owned();
3475    };
3476
3477    match value {
3478        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
3479        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
3480        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
3481        _ => "None".to_owned(),
3482    }
3483}
3484
3485fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
3486    value
3487        .as_str()
3488        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
3489}
3490
3491fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
3492    sources.sort_by(|left, right| {
3493        left.explicit
3494            .cmp(&right.explicit)
3495            .then_with(|| right.priority.cmp(&left.priority))
3496            .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
3497    });
3498    sources
3499}
3500
3501fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
3502    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
3503        return None;
3504    }
3505
3506    let mut sources = SourceStore::default().sources;
3507    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
3508        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
3509            continue;
3510        };
3511
3512        if yaml_bool(entry.get("IsTombstone")) {
3513            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
3514            continue;
3515        }
3516
3517        let Some(mapped) = map_packaged_source_entry(&entry) else {
3518            continue;
3519        };
3520
3521        if let Some(existing) = sources
3522            .iter_mut()
3523            .find(|source| source.name.eq_ignore_ascii_case(&name))
3524        {
3525            *existing = mapped;
3526        } else {
3527            sources.push(mapped);
3528        }
3529    }
3530
3531    for entry in parse_packaged_yaml_entries(metadata_yaml) {
3532        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
3533            continue;
3534        };
3535
3536        let Some(source) = sources
3537            .iter_mut()
3538            .find(|source| source.name.eq_ignore_ascii_case(&name))
3539        else {
3540            continue;
3541        };
3542
3543        if let Some(last_update) = entry
3544            .get("LastUpdate")
3545            .and_then(|value| value.as_deref())
3546            .and_then(parse_packaged_datetime)
3547        {
3548            source.last_update = Some(last_update);
3549        }
3550
3551        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
3552            && !source_version.is_empty()
3553        {
3554            source.source_version = Some(source_version);
3555        }
3556    }
3557
3558    Some(SourceStore { sources })
3559}
3560
3561fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
3562    let Some(yaml) = yaml else {
3563        return Vec::new();
3564    };
3565
3566    let mut entries = Vec::new();
3567    let mut current: Option<BTreeMap<String, Option<String>>> = None;
3568    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
3569        let line = raw_line.trim_end();
3570        let trimmed = line.trim();
3571        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
3572            continue;
3573        }
3574
3575        if let Some(remainder) = trimmed.strip_prefix("- ") {
3576            if let Some(entry) = current.take() {
3577                entries.push(entry);
3578            }
3579
3580            let mut entry = BTreeMap::new();
3581            add_packaged_yaml_key_value(&mut entry, remainder);
3582            current = Some(entry);
3583            continue;
3584        }
3585
3586        if let Some(entry) = current.as_mut() {
3587            add_packaged_yaml_key_value(entry, trimmed);
3588        }
3589    }
3590
3591    if let Some(entry) = current {
3592        entries.push(entry);
3593    }
3594
3595    entries
3596}
3597
3598fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
3599    let Some((key, value)) = line.split_once(':') else {
3600        return;
3601    };
3602
3603    entry.insert(
3604        key.trim().to_owned(),
3605        Some(unquote_yaml_scalar(value.trim()).to_owned()),
3606    );
3607}
3608
3609fn unquote_yaml_scalar(value: &str) -> &str {
3610    if value.len() >= 2 {
3611        let bytes = value.as_bytes();
3612        let first = bytes[0] as char;
3613        let last = bytes[value.len() - 1] as char;
3614        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
3615            return &value[1..value.len() - 1];
3616        }
3617    }
3618
3619    value
3620}
3621
3622fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
3623    let name = entry.get("Name").and_then(|value| value.clone())?;
3624    let source_type = entry.get("Type").and_then(|value| value.clone())?;
3625    let arg = entry.get("Arg").and_then(|value| value.clone())?;
3626    let kind = parse_packaged_source_kind(&source_type)?;
3627
3628    Some(SourceRecord {
3629        name: name.clone(),
3630        kind,
3631        arg,
3632        identifier: entry
3633            .get("Identifier")
3634            .and_then(|value| value.clone())
3635            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
3636            .unwrap_or(name),
3637        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
3638        explicit: yaml_bool(entry.get("Explicit")),
3639        priority: entry
3640            .get("Priority")
3641            .and_then(|value| value.as_deref())
3642            .and_then(|value| value.parse::<i32>().ok())
3643            .unwrap_or_default(),
3644        last_update: None,
3645        source_version: None,
3646    })
3647}
3648
3649fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
3650    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
3651        Some(SourceKind::PreIndexed)
3652    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
3653        Some(SourceKind::Rest)
3654    } else {
3655        None
3656    }
3657}
3658
3659fn parse_packaged_trust_level(value: Option<&str>) -> String {
3660    match value {
3661        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
3662        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
3663        _ => "None".to_owned(),
3664    }
3665}
3666
3667fn yaml_bool(value: Option<&Option<String>>) -> bool {
3668    value
3669        .and_then(|raw| raw.as_deref())
3670        .and_then(|raw| raw.parse::<bool>().ok())
3671        .unwrap_or(false)
3672}
3673
3674fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
3675    if let Ok(unix_seconds) = value.parse::<i64>() {
3676        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
3677    }
3678
3679    DateTime::parse_from_rfc3339(value)
3680        .ok()
3681        .map(|value| value.with_timezone(&Utc))
3682}
3683
3684fn render_packaged_sources_yaml(store: &SourceStore) -> String {
3685    let mut lines = vec!["Sources:".to_owned()];
3686    for source in &store.sources {
3687        lines.push(format!("  - Name: {}", source.name));
3688        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
3689        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
3690        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
3691        lines.push(format!(
3692            "    TrustLevel: {}",
3693            if source.trust_level.eq_ignore_ascii_case("Trusted") {
3694                1
3695            } else {
3696                0
3697            }
3698        ));
3699        lines.push(format!("    Explicit: {}", source.explicit));
3700        lines.push(format!("    Priority: {}", source.priority));
3701        lines.push("    IsTombstone: false".to_owned());
3702    }
3703    lines.push(String::new());
3704    lines.join("\n")
3705}
3706
3707fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
3708    let mut lines = vec!["Sources:".to_owned()];
3709    for source in &store.sources {
3710        if source.last_update.is_none() && source.source_version.is_none() {
3711            continue;
3712        }
3713
3714        lines.push(format!("  - Name: {}", source.name));
3715        if let Some(last_update) = source.last_update {
3716            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
3717        }
3718        if let Some(source_version) = &source.source_version {
3719            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
3720        }
3721    }
3722    lines.push(String::new());
3723    lines.join("\n")
3724}
3725
3726fn yaml_scalar(value: &str) -> String {
3727    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
3728        format!("\"{}\"", value.replace('"', "\\\""))
3729    } else {
3730        value.to_owned()
3731    }
3732}
3733
3734fn uses_packaged_layout(app_root: &Path) -> bool {
3735    #[cfg(windows)]
3736    {
3737        try_get_packaged_local_state_root(app_root).is_some()
3738    }
3739
3740    #[cfg(not(windows))]
3741    {
3742        let _ = app_root;
3743        false
3744    }
3745}
3746
3747#[cfg(windows)]
3748fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
3749    let local_state = app_root.file_name()?.to_string_lossy();
3750    if !local_state.eq_ignore_ascii_case("LocalState") {
3751        return None;
3752    }
3753
3754    let family = app_root.parent()?.file_name()?.to_string_lossy();
3755    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
3756        return None;
3757    }
3758
3759    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
3760    if !packages.eq_ignore_ascii_case("Packages") {
3761        return None;
3762    }
3763
3764    Some(app_root.to_path_buf())
3765}
3766
3767#[cfg(windows)]
3768fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
3769    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
3770    Ok(packaged_secure_settings_root()?.join("user_sources"))
3771}
3772
3773#[cfg(not(windows))]
3774fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
3775    bail!("packaged WinGet source streams are only available on Windows")
3776}
3777
3778#[cfg(windows)]
3779fn packaged_secure_settings_root() -> Result<PathBuf> {
3780    let program_data = std::env::var_os("PROGRAMDATA")
3781        .map(PathBuf::from)
3782        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
3783    Ok(program_data
3784        .join("Microsoft")
3785        .join("WinGet")
3786        .join(current_user_sid()?)
3787        .join("settings")
3788        .join("pkg")
3789        .join(PACKAGED_NAME))
3790}
3791
3792#[cfg(windows)]
3793#[allow(clippy::multiple_unsafe_ops_per_block)]
3794fn current_user_sid() -> Result<String> {
3795    fn close_token(token: HANDLE) {
3796        if !token.is_null() {
3797            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
3798            unsafe {
3799                CloseHandle(token);
3800            }
3801        }
3802    }
3803
3804    let mut token: HANDLE = std::ptr::null_mut();
3805    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
3806    let current_process = unsafe { GetCurrentProcess() };
3807    // SAFETY: token points to valid storage for the process token handle on success.
3808    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
3809        bail!("failed to open current process token")
3810    }
3811
3812    let mut length = 0;
3813    // SAFETY: The null buffer call asks Windows for the required buffer length.
3814    unsafe {
3815        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
3816    }
3817
3818    let element_size = size_of::<TOKEN_USER>();
3819    let element_count = (length as usize).div_ceil(element_size);
3820    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
3821    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
3822    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
3823        close_token(token);
3824        bail!("failed to get current user token information")
3825    }
3826
3827    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
3828    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
3829    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
3830    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
3831    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
3832        close_token(token);
3833        bail!("failed to convert current user SID")
3834    }
3835
3836    let mut len = 0usize;
3837    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
3838    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
3839        len += 1;
3840    }
3841
3842    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
3843    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
3844    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
3845    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
3846    unsafe {
3847        LocalFree(sid_ptr.cast());
3848    }
3849    close_token(token);
3850    Ok(sid)
3851}
3852
3853fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
3854    app_root.join("Microsoft").join("Windows Package Manager")
3855}
3856
3857fn cache_root(app_root: &Path) -> PathBuf {
3858    if uses_packaged_layout(app_root) {
3859        packaged_file_cache_root(app_root)
3860    } else {
3861        default_cache_root_fallback()
3862    }
3863}
3864
3865fn default_cache_root_fallback() -> PathBuf {
3866    std::env::temp_dir().join("cache")
3867}
3868
3869fn packaged_source_type(kind: SourceKind) -> &'static str {
3870    match kind {
3871        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
3872        SourceKind::Rest => "Microsoft.Rest",
3873    }
3874}
3875
3876fn sanitize_path_segment(value: &str) -> String {
3877    value
3878        .chars()
3879        .map(|ch| match ch {
3880            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
3881            _ => ch,
3882        })
3883        .collect()
3884}
3885
3886fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
3887    if !path.exists() {
3888        return Ok(JsonMap::new());
3889    }
3890
3891    let bytes = fs::read(path).context("failed to read JSON file")?;
3892    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
3893    Ok(match value {
3894        JsonValue::Object(object) => object,
3895        _ => JsonMap::new(),
3896    })
3897}
3898
3899fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
3900    write_json(path, value)
3901}
3902
3903fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
3904    if let Some(parent) = path.parent() {
3905        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
3906    }
3907
3908    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
3909    fs::write(path, bytes).context("failed to write JSON file")
3910}
3911
3912fn merge_json_objects(
3913    current: &JsonMap<String, JsonValue>,
3914    update: &JsonMap<String, JsonValue>,
3915) -> JsonMap<String, JsonValue> {
3916    let mut merged = current.clone();
3917    for (key, value) in update {
3918        match (merged.get(key), value) {
3919            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
3920                merged.insert(
3921                    key.clone(),
3922                    JsonValue::Object(merge_json_objects(current_object, update_object)),
3923                );
3924            }
3925            _ => {
3926                merged.insert(key.clone(), value.clone());
3927            }
3928        }
3929    }
3930
3931    merged
3932}
3933
3934fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
3935    match expected {
3936        JsonValue::Object(expected_object) => match current {
3937            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
3938                current_object
3939                    .get(key)
3940                    .is_some_and(|current_child| json_contains(current_child, expected_child))
3941            }),
3942            _ => false,
3943        },
3944        JsonValue::Array(expected_array) => match current {
3945            JsonValue::Array(current_array) => {
3946                current_array.len() == expected_array.len()
3947                    && current_array
3948                        .iter()
3949                        .zip(expected_array.iter())
3950                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
3951            }
3952            _ => false,
3953        },
3954        _ => current == expected,
3955    }
3956}
3957
3958fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
3959    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
3960        "none" | "default" => Ok("None".to_owned()),
3961        "trusted" => Ok("Trusted".to_owned()),
3962        other => bail!("unsupported source trust level: {other}"),
3963    }
3964}
3965
3966fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
3967    SUPPORTED_ADMIN_SETTINGS
3968        .iter()
3969        .copied()
3970        .find(|setting| setting.eq_ignore_ascii_case(name))
3971        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
3972}
3973
3974fn default_source_trust_level() -> String {
3975    "None".to_owned()
3976}
3977
3978fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
3979where
3980    F: FnMut(&SqlRow<'_>) -> Result<T>,
3981{
3982    let mut statement = connection
3983        .prepare(sql)
3984        .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
3985    let mut rows = statement
3986        .query(params_from_iter(params))
3987        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
3988    let mut result = Vec::new();
3989
3990    while let Some(row) = rows.next().context("failed to read SQL row")? {
3991        result.push(map(row)?);
3992    }
3993
3994    Ok(result)
3995}
3996
3997fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
3998where
3999    F: FnOnce(&SqlRow<'_>) -> Result<T>,
4000{
4001    let mut statement = connection
4002        .prepare(sql)
4003        .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4004    let mut rows = statement
4005        .query(params_from_iter(params))
4006        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4007
4008    match rows.next().context("failed to read SQL row")? {
4009        Some(row) => Ok(Some(map(row)?)),
4010        None => Ok(None),
4011    }
4012}
4013
4014fn row_ref<'a>(row: &'a SqlRow<'_>, index: usize) -> Result<ValueRef<'a>> {
4015    row.get_ref(index)
4016        .with_context(|| format!("failed to read SQL column {index}"))
4017}
4018
4019fn row_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4020    match row_ref(row, index)? {
4021        ValueRef::Text(value) => std::str::from_utf8(value)
4022            .context("SQL text column was not valid UTF-8")
4023            .map(str::to_owned),
4024        ValueRef::Integer(value) => Ok(value.to_string()),
4025        ValueRef::Real(value) => Ok(value.to_string()),
4026        ValueRef::Blob(value) => String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8"),
4027        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4028    }
4029}
4030
4031fn row_opt_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4032    match row_ref(row, index)? {
4033        ValueRef::Null => Ok(None),
4034        ValueRef::Text(value) => Ok(Some(
4035            std::str::from_utf8(value)
4036                .context("SQL text column was not valid UTF-8")?
4037                .to_owned(),
4038        )),
4039        ValueRef::Integer(value) => Ok(Some(value.to_string())),
4040        ValueRef::Real(value) => Ok(Some(value.to_string())),
4041        ValueRef::Blob(value) => Ok(Some(
4042            String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8")?,
4043        )),
4044    }
4045}
4046
4047fn row_i64(row: &SqlRow<'_>, index: usize) -> Result<i64> {
4048    match row_ref(row, index)? {
4049        ValueRef::Integer(value) => Ok(value),
4050        ValueRef::Text(value) => std::str::from_utf8(value)
4051            .context("SQL text column was not valid UTF-8")?
4052            .parse::<i64>()
4053            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
4054        ValueRef::Real(value) => f64_to_i64_checked(value, index),
4055        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4056        ValueRef::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
4057    }
4058}
4059
4060fn row_hex_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4061    match row_ref(row, index)? {
4062        ValueRef::Blob(value) => Ok(bytes_to_hex(value)),
4063        ValueRef::Text(value) => Ok(std::str::from_utf8(value)
4064            .context("SQL text column was not valid UTF-8")?
4065            .to_ascii_lowercase()),
4066        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4067        ValueRef::Integer(value) => Ok(format!("{value:x}")),
4068        ValueRef::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
4069    }
4070}
4071
4072fn row_opt_hex_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4073    match row_ref(row, index)? {
4074        ValueRef::Null => Ok(None),
4075        ValueRef::Blob(value) => Ok(Some(bytes_to_hex(value))),
4076        ValueRef::Text(value) => Ok(Some(
4077            std::str::from_utf8(value)
4078                .context("SQL text column was not valid UTF-8")?
4079                .to_ascii_lowercase(),
4080        )),
4081        ValueRef::Integer(value) => Ok(Some(format!("{value:x}"))),
4082        ValueRef::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
4083    }
4084}
4085
4086fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
4087    if !value.is_finite() {
4088        bail!("SQL column {index} contained a non-finite floating-point value");
4089    }
4090
4091    if value.fract() != 0.0 {
4092        bail!("SQL column {index} contained a non-integer floating-point value");
4093    }
4094
4095    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
4096        bail!("SQL column {index} contained a floating-point value outside the i64 range");
4097    }
4098
4099    format!("{value:.0}")
4100        .parse::<i64>()
4101        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
4102}
4103
4104fn bytes_to_hex(bytes: &[u8]) -> String {
4105    let mut result = String::with_capacity(bytes.len() * 2);
4106    for byte in bytes {
4107        result.push_str(&format!("{byte:02x}"));
4108    }
4109    result
4110}
4111
4112fn query_v2_matches(
4113    connection: &Connection,
4114    query: &PackageQuery,
4115    semantics: SearchSemantics,
4116) -> Result<(Vec<V2SearchRow>, bool)> {
4117    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
4118    let limit = source_fetch_results(query, semantics);
4119    let sql = format!(
4120        "SELECT rowid, id, name, moniker, latest_version, hash \
4121         FROM packages WHERE {where_clause} LIMIT {}",
4122        limit + 1
4123    );
4124    let mut rows = query_rows(
4125        connection,
4126        &sql,
4127        params.into_iter().map(SqlValue::Text).collect(),
4128        |row| {
4129            Ok(V2SearchRow {
4130                package_rowid: row_i64(row, 0)?,
4131                id: row_string(row, 1)?,
4132                name: row_string(row, 2)?,
4133                moniker: row_opt_string(row, 3)?,
4134                version: row_string(row, 4)?,
4135                package_hash: row_hex_string(row, 5)?,
4136                match_criteria: None,
4137            })
4138        },
4139    )?;
4140    for row in &mut rows {
4141        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
4142    }
4143    let truncated = rows.len() > limit;
4144    rows.truncate(limit);
4145    Ok((rows, truncated))
4146}
4147
4148fn query_v1_matches(
4149    connection: &Connection,
4150    query: &PackageQuery,
4151    semantics: SearchSemantics,
4152) -> Result<(Vec<V1SearchRow>, bool)> {
4153    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
4154    let limit = source_fetch_results(query, semantics);
4155    let sql = format!(
4156        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
4157         FROM manifest \
4158         JOIN ids ON manifest.id = ids.rowid \
4159         JOIN names ON manifest.name = names.rowid \
4160         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
4161         JOIN versions ON manifest.version = versions.rowid \
4162         JOIN channels ON manifest.channel = channels.rowid \
4163         WHERE {where_clause} LIMIT {}",
4164        limit + 1
4165    );
4166    let mut rows = query_rows(
4167        connection,
4168        &sql,
4169        params.into_iter().map(SqlValue::Text).collect(),
4170        |row| {
4171            Ok(V1SearchRow {
4172                manifest_rowid: row_i64(row, 0)?,
4173                package_rowid: row_i64(row, 1)?,
4174                version: row_string(row, 2)?,
4175                channel: row_string(row, 3)?,
4176                id: row_string(row, 4)?,
4177                name: row_string(row, 5)?,
4178                moniker: row_opt_string(row, 6)?,
4179                match_criteria: None,
4180            })
4181        },
4182    )?;
4183    for row in &mut rows {
4184        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
4185    }
4186    let truncated = rows.len() > limit;
4187    rows.truncate(limit);
4188    Ok((rows, truncated))
4189}
4190
4191fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
4192    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
4193
4194    for row in rows {
4195        match grouped.get(&row.package_rowid) {
4196            Some(existing)
4197                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
4198                    != Ordering::Less => {}
4199            _ => {
4200                grouped.insert(row.package_rowid, row);
4201            }
4202        }
4203    }
4204
4205    grouped.into_values().collect()
4206}
4207
4208fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
4209    let has_hash = table_has_column(connection, "manifest", "hash")?;
4210    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
4211    let sql = format!(
4212        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
4213         FROM manifest \
4214         JOIN versions ON manifest.version = versions.rowid \
4215         JOIN channels ON manifest.channel = channels.rowid \
4216         WHERE manifest.id = ?1"
4217    );
4218    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
4219        Ok(V1VersionRow {
4220            version: row_string(row, 0)?,
4221            channel: row_string(row, 1)?,
4222            pathpart_id: row_i64(row, 2)?,
4223            manifest_hash: row_opt_hex_string(row, 3)?,
4224        })
4225    })
4226}
4227
4228fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
4229    let mut parts = Vec::new();
4230    let mut current = Some(pathpart_id);
4231
4232    while let Some(id) = current {
4233        let (parent, pathpart) = query_optional_value(
4234            connection,
4235            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
4236            vec![SqlValue::Integer(id)],
4237            |row| {
4238                Ok((
4239                    row_opt_string(row, 0)?
4240                        .and_then(|value| value.parse::<i64>().ok())
4241                        .filter(|value| *value != 0),
4242                    row_string(row, 1)?,
4243                ))
4244            },
4245        )?
4246        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
4247        parts.push(pathpart);
4248        current = parent;
4249    }
4250
4251    parts.reverse();
4252    Ok(parts.join("/"))
4253}
4254
4255fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
4256    format!(
4257        "packages/{}/{}/versionData.mszyml",
4258        package_id,
4259        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
4260    )
4261}
4262
4263fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
4264    let message = format!("{error:#}").to_ascii_lowercase();
4265    message.contains("no such table") && message.contains("packages")
4266}
4267
4268fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
4269    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
4270        row_string(row, 1)
4271    })? {
4272        if value.eq_ignore_ascii_case(column) {
4273            return Ok(true);
4274        }
4275    }
4276
4277    Ok(false)
4278}
4279
4280fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
4281    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
4282    let id_column = if v2 { "id" } else { "ids.id" };
4283    let name_column = if v2 { "name" } else { "names.name" };
4284    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
4285    let exact_match = query.exact || semantics == SearchSemantics::Single;
4286    let mut params = Vec::new();
4287
4288    if let Some(value) = &query.id {
4289        return (
4290            single_field_condition(id_column, value, exact_match, &mut params),
4291            params,
4292        );
4293    }
4294    if let Some(value) = &query.name {
4295        return (
4296            single_field_condition(name_column, value, exact_match, &mut params),
4297            params,
4298        );
4299    }
4300    if let Some(value) = &query.moniker {
4301        return (
4302            single_field_condition(moniker_column, value, exact_match, &mut params),
4303            params,
4304        );
4305    }
4306    if let Some(value) = &query.tag {
4307        return (
4308            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
4309            params,
4310        );
4311    }
4312    if let Some(value) = &query.command {
4313        return (
4314            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
4315            params,
4316        );
4317    }
4318    if let Some(value) = &query.query {
4319        if exact_match {
4320            let conditions = [
4321                single_field_condition(id_column, value, true, &mut params),
4322                single_field_condition(name_column, value, true, &mut params),
4323                single_field_condition(moniker_column, value, true, &mut params),
4324            ];
4325            return (format!("({})", conditions.join(" OR ")), params);
4326        }
4327
4328        let mut conditions = vec![
4329            single_field_condition(id_column, value, false, &mut params),
4330            single_field_condition(name_column, value, false, &mut params),
4331            single_field_condition(moniker_column, value, false, &mut params),
4332        ];
4333        if semantics == SearchSemantics::Many {
4334            conditions.push(mapped_field_condition(
4335                v2,
4336                "tag",
4337                value,
4338                rowid_column,
4339                false,
4340                &mut params,
4341            ));
4342            conditions.push(mapped_field_condition(
4343                v2,
4344                "command",
4345                value,
4346                rowid_column,
4347                false,
4348                &mut params,
4349            ));
4350        }
4351        return (format!("({})", conditions.join(" OR ")), params);
4352    }
4353
4354    ("1 = 1".to_owned(), Vec::new())
4355}
4356
4357fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
4358    params.push(match_parameter(value, exact));
4359    format!("{column} LIKE ?{}", params.len())
4360}
4361
4362fn mapped_field_condition(
4363    v2: bool,
4364    value_name: &str,
4365    value: &str,
4366    rowid_column: &str,
4367    exact: bool,
4368    params: &mut Vec<String>,
4369) -> String {
4370    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
4371        (
4372            format!("{value_name}s2"),
4373            format!("{value_name}s2_map"),
4374            value_name.to_owned(),
4375            "package".to_owned(),
4376        )
4377    } else {
4378        (
4379            format!("{value_name}s"),
4380            format!("{value_name}s_map"),
4381            value_name.to_owned(),
4382            "manifest".to_owned(),
4383        )
4384    };
4385    params.push(match_parameter(value, exact));
4386    let parameter = params.len();
4387    format!(
4388        "EXISTS (SELECT 1 FROM {map_table_name} JOIN {table_name} ON \
4389         {map_table_name}.{value_name} = {table_name}.rowid \
4390         WHERE {map_table_name}.{map_owner_column} = {rowid_column} \
4391         AND {table_name}.{map_value_column} LIKE ?{parameter})"
4392    )
4393}
4394
4395fn match_parameter(value: &str, exact: bool) -> String {
4396    if exact { value.to_owned() } else { format!("%{value}%") }
4397}
4398
4399fn build_rest_search_body(
4400    query: &PackageQuery,
4401    info: &RestInformation,
4402    semantics: SearchSemantics,
4403) -> Result<JsonValue> {
4404    let mut root = serde_json::Map::new();
4405    root.insert(
4406        "MaximumResults".to_owned(),
4407        JsonValue::from(source_fetch_results(query, semantics) as u64),
4408    );
4409    let exact_match = query.exact || semantics == SearchSemantics::Single;
4410
4411    if let Some(value) = &query.query {
4412        if semantics == SearchSemantics::Single {
4413            let mut filters = vec![
4414                rest_filter("PackageIdentifier", value, true),
4415                rest_filter("PackageName", value, true),
4416                rest_filter("Moniker", value, true),
4417            ];
4418            append_required_rest_filters(&mut filters, info);
4419            root.insert("Filters".to_owned(), JsonValue::Array(filters));
4420            return Ok(JsonValue::Object(root));
4421        }
4422
4423        root.insert(
4424            "Query".to_owned(),
4425            serde_json::json!({
4426                "KeyWord": value,
4427                "MatchType": if exact_match { "Exact" } else { "Substring" },
4428            }),
4429        );
4430    }
4431
4432    let mut filters = Vec::new();
4433    if let Some(value) = &query.id {
4434        filters.push(rest_filter("PackageIdentifier", value, exact_match));
4435    }
4436    if let Some(value) = &query.name {
4437        filters.push(rest_filter("PackageName", value, exact_match));
4438    }
4439    if let Some(value) = &query.moniker {
4440        filters.push(rest_filter("Moniker", value, exact_match));
4441    }
4442    if let Some(value) = &query.tag {
4443        filters.push(rest_filter("Tag", value, true));
4444    }
4445    if let Some(value) = &query.command {
4446        filters.push(rest_filter("Command", value, true));
4447    }
4448    append_required_rest_filters(&mut filters, info);
4449
4450    if !filters.is_empty() {
4451        root.insert("Filters".to_owned(), JsonValue::Array(filters));
4452    }
4453
4454    Ok(JsonValue::Object(root))
4455}
4456
4457fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
4458    if info
4459        .required_package_match_fields
4460        .iter()
4461        .any(|field| field.eq_ignore_ascii_case("Market"))
4462    {
4463        filters.push(rest_filter("Market", &default_market(), true));
4464    }
4465}
4466
4467fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
4468    serde_json::json!({
4469        "PackageMatchField": field,
4470        "RequestMatch": {
4471            "KeyWord": value,
4472            "MatchType": if exact { "Exact" } else { "Substring" },
4473        }
4474    })
4475}
4476
4477fn max_results(query: &PackageQuery) -> usize {
4478    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
4479}
4480
4481fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
4482    match semantics {
4483        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
4484        SearchSemantics::Single => max_results(query),
4485    }
4486}
4487
4488fn infer_preindexed_match_criteria_v2(
4489    connection: &Connection,
4490    row: &V2SearchRow,
4491    query: &PackageQuery,
4492    semantics: SearchSemantics,
4493) -> Result<Option<String>> {
4494    infer_match_criteria(
4495        &row.id,
4496        &row.name,
4497        row.moniker.as_deref(),
4498        query,
4499        semantics,
4500        |value| {
4501            find_mapped_value_v2(
4502                connection,
4503                "tags2",
4504                "tags2_map",
4505                "tag",
4506                row.package_rowid,
4507                value,
4508                query.exact,
4509            )
4510        },
4511        |value| {
4512            find_mapped_value_v2(
4513                connection,
4514                "commands2",
4515                "commands2_map",
4516                "command",
4517                row.package_rowid,
4518                value,
4519                query.exact,
4520            )
4521        },
4522    )
4523}
4524
4525fn infer_preindexed_match_criteria_v1(
4526    connection: &Connection,
4527    row: &V1SearchRow,
4528    query: &PackageQuery,
4529    semantics: SearchSemantics,
4530) -> Result<Option<String>> {
4531    infer_match_criteria(
4532        &row.id,
4533        &row.name,
4534        row.moniker.as_deref(),
4535        query,
4536        semantics,
4537        |value| {
4538            find_mapped_value_v1(
4539                connection,
4540                "tags",
4541                "tags_map",
4542                "tag",
4543                row.manifest_rowid,
4544                value,
4545                query.exact,
4546            )
4547        },
4548        |value| {
4549            find_mapped_value_v1(
4550                connection,
4551                "commands",
4552                "commands_map",
4553                "command",
4554                row.manifest_rowid,
4555                value,
4556                query.exact,
4557            )
4558        },
4559    )
4560}
4561
4562fn infer_match_criteria<FTag, FCommand>(
4563    id: &str,
4564    name: &str,
4565    moniker: Option<&str>,
4566    query: &PackageQuery,
4567    semantics: SearchSemantics,
4568    find_tag: FTag,
4569    find_command: FCommand,
4570) -> Result<Option<String>>
4571where
4572    FTag: FnOnce(&str) -> Result<Option<String>>,
4573    FCommand: FnOnce(&str) -> Result<Option<String>>,
4574{
4575    if let Some(value) = &query.tag {
4576        return Ok(Some(format_match_criteria("Tag", value)));
4577    }
4578    if let Some(value) = &query.command {
4579        return Ok(Some(format_match_criteria("Command", value)));
4580    }
4581    if let Some(value) = &query.moniker {
4582        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
4583    }
4584    if semantics == SearchSemantics::Single {
4585        return Ok(None);
4586    }
4587    if let Some(value) = &query.query {
4588        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
4589            return Ok(None);
4590        }
4591        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
4592            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
4593        }
4594        if let Some(tag) = find_tag(value)? {
4595            return Ok(Some(format_match_criteria("Tag", &tag)));
4596        }
4597        if let Some(command) = find_command(value)? {
4598            return Ok(Some(format_match_criteria("Command", &command)));
4599        }
4600    }
4601    Ok(None)
4602}
4603
4604fn find_mapped_value_v2(
4605    connection: &Connection,
4606    table_name: &str,
4607    map_table_name: &str,
4608    value_name: &str,
4609    package_rowid: i64,
4610    query: &str,
4611    exact: bool,
4612) -> Result<Option<String>> {
4613    let sql = format!(
4614        "SELECT {table_name}.{value_name} FROM {map_table_name} \
4615         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
4616         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
4617    );
4618    let values = query_rows(
4619        connection,
4620        &sql,
4621        vec![
4622            SqlValue::Integer(package_rowid),
4623            SqlValue::Text(match_parameter(query, exact)),
4624        ],
4625        |row| row_string(row, 0),
4626    )?;
4627    Ok(select_best_text_match(values, query, exact))
4628}
4629
4630fn find_mapped_value_v1(
4631    connection: &Connection,
4632    table_name: &str,
4633    map_table_name: &str,
4634    value_name: &str,
4635    manifest_rowid: i64,
4636    query: &str,
4637    exact: bool,
4638) -> Result<Option<String>> {
4639    let sql = format!(
4640        "SELECT {table_name}.{value_name} FROM {map_table_name} \
4641         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
4642         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
4643    );
4644    let values = query_rows(
4645        connection,
4646        &sql,
4647        vec![
4648            SqlValue::Integer(manifest_rowid),
4649            SqlValue::Text(match_parameter(query, exact)),
4650        ],
4651        |row| row_string(row, 0),
4652    )?;
4653    Ok(select_best_text_match(values, query, exact))
4654}
4655
4656fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
4657    if let Some(value) = &query.tag {
4658        return Some(format_match_criteria("Tag", value));
4659    }
4660    if let Some(value) = &query.command {
4661        return Some(format_match_criteria("Command", value));
4662    }
4663    if let Some(value) = &query.moniker {
4664        return Some(format_match_criteria("Moniker", value));
4665    }
4666    if semantics == SearchSemantics::Single {
4667        return None;
4668    }
4669    let value = query.query.as_deref()?;
4670    if json_string(item, "PackageIdentifier")
4671        .as_deref()
4672        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
4673        || json_string(item, "PackageName")
4674            .as_deref()
4675            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
4676    {
4677        return None;
4678    }
4679    json_string(item, "Moniker")
4680        .filter(|candidate| matches_text(candidate, value, query.exact))
4681        .map(|candidate| format_match_criteria("Moniker", &candidate))
4682}
4683
4684fn format_match_criteria(field: &str, value: &str) -> String {
4685    format!("{field}: {value}")
4686}
4687
4688fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
4689    if exact {
4690        candidate.eq_ignore_ascii_case(query)
4691    } else {
4692        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
4693    }
4694}
4695
4696fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
4697    let mut score = 0;
4698
4699    if let Some(value) = &query.query {
4700        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
4701        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
4702        if let Some(moniker) = candidate.moniker.as_deref() {
4703            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
4704        }
4705    }
4706
4707    if let Some(value) = &query.id {
4708        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
4709    }
4710    if let Some(value) = &query.name {
4711        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
4712    }
4713    if let Some(value) = &query.moniker
4714        && let Some(moniker) = candidate.moniker.as_deref()
4715    {
4716        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
4717    }
4718    if let Some(value) = &query.tag
4719        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
4720    {
4721        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
4722    }
4723    if let Some(value) = &query.command
4724        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
4725    {
4726        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
4727    }
4728
4729    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
4730        score += 5;
4731    }
4732    if search_match_has_unknown_version(candidate) {
4733        score = score.saturating_sub(10);
4734    }
4735
4736    score
4737}
4738
4739fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
4740    candidate
4741        .version
4742        .as_deref()
4743        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
4744}
4745
4746fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
4747    if manifest_path.is_file() {
4748        return Ok(manifest_path.to_path_buf());
4749    }
4750
4751    if !manifest_path.is_dir() {
4752        bail!("Manifest path not found: {}", manifest_path.display());
4753    }
4754
4755    let candidate = fs::read_dir(manifest_path)?
4756        .filter_map(|entry| entry.ok())
4757        .map(|entry| entry.path())
4758        .filter(|path| {
4759            path.extension()
4760                .and_then(|value| value.to_str())
4761                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
4762        })
4763        .min();
4764
4765    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
4766}
4767
4768fn score_text_match(
4769    candidate: &str,
4770    query: &str,
4771    exact: bool,
4772    exact_score: usize,
4773    prefix_score: usize,
4774    substring_score: usize,
4775) -> usize {
4776    if candidate.eq_ignore_ascii_case(query) {
4777        return exact_score;
4778    }
4779    if exact {
4780        return 0;
4781    }
4782
4783    let candidate_lower = candidate.to_ascii_lowercase();
4784    let query_lower = query.to_ascii_lowercase();
4785    if candidate_lower.starts_with(&query_lower) {
4786        prefix_score
4787    } else if candidate_lower.contains(&query_lower) {
4788        substring_score
4789    } else {
4790        0
4791    }
4792}
4793
4794fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
4795where
4796    I: IntoIterator<Item = String>,
4797{
4798    values
4799        .into_iter()
4800        .filter_map(|candidate| {
4801            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
4802            (score > 0).then_some((score, candidate))
4803        })
4804        .max_by(|(left_score, left_value), (right_score, right_value)| {
4805            left_score
4806                .cmp(right_score)
4807                .then_with(|| right_value.len().cmp(&left_value.len()))
4808                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
4809        })
4810        .map(|(_, candidate)| candidate)
4811}
4812
4813fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
4814    let (field, value) = criteria.split_once(": ")?;
4815    Some((field, value))
4816}
4817
4818fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
4819    let versions = item
4820        .get("Versions")
4821        .and_then(JsonValue::as_array)
4822        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
4823    let mut result = Vec::new();
4824    for version in versions {
4825        let value = json_string(version, "PackageVersion")
4826            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
4827        let channel = json_string(version, "Channel").unwrap_or_default();
4828        result.push(VersionKey {
4829            version: value,
4830            channel,
4831        });
4832    }
4833    Ok(result)
4834}
4835
4836fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
4837    let mut merged = YamlMapping::new();
4838    let mut documents = Vec::new();
4839    for document in serde_yaml::Deserializer::from_slice(bytes) {
4840        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
4841        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
4842        if let Some(mapping) = value.as_mapping() {
4843            merge_yaml_mapping(&mut merged, mapping);
4844        }
4845    }
4846
4847    let id = yaml_string_from_root(&merged, "PackageIdentifier")
4848        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
4849    let version =
4850        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
4851    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
4852
4853    let installers = parse_yaml_installers(&merged);
4854
4855    Ok((
4856        Manifest {
4857            id,
4858            name,
4859            version,
4860            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
4861            publisher: yaml_localized_string(&merged, "Publisher"),
4862            description: yaml_localized_string(&merged, "Description")
4863                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
4864            moniker: yaml_string_from_root(&merged, "Moniker"),
4865            package_url: yaml_localized_string(&merged, "PackageUrl"),
4866            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
4867            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
4868            license: yaml_localized_string(&merged, "License"),
4869            license_url: yaml_localized_string(&merged, "LicenseUrl"),
4870            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
4871            author: yaml_localized_string(&merged, "Author"),
4872            copyright: yaml_localized_string(&merged, "Copyright"),
4873            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
4874            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
4875            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
4876            tags: yaml_string_list(&merged, "Tags"),
4877            agreements: yaml_agreement_list(&merged),
4878            package_dependencies: yaml_package_dependencies(&merged),
4879            documentation: yaml_documentation_list(&merged),
4880            installers,
4881        },
4882        collapse_structured_document(&JsonValue::Array(documents)),
4883    ))
4884}
4885
4886fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
4887    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
4888}
4889
4890fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
4891    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
4892    let data = root
4893        .get("Data")
4894        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
4895    let versions = data
4896        .get("Versions")
4897        .and_then(JsonValue::as_array)
4898        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
4899    let selected = versions
4900        .iter()
4901        .find(|item| {
4902            json_string(item, "PackageVersion").as_deref() == Some(version)
4903                && json_string(item, "Channel").unwrap_or_default() == channel
4904        })
4905        .or_else(|| versions.first())
4906        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
4907
4908    let default_locale = selected
4909        .get("DefaultLocale")
4910        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
4911    let name = json_string(default_locale, "PackageName")
4912        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
4913    let installer_switch_defaults = json_installer_switches(selected);
4914    let top_platforms = json_string_list(selected, "Platform");
4915    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
4916
4917    let installers = selected
4918        .get("Installers")
4919        .and_then(JsonValue::as_array)
4920        .map(|items| {
4921            items
4922                .iter()
4923                .map(|item| Installer {
4924                    platforms: {
4925                        let platforms = json_string_list(item, "Platform");
4926                        if platforms.is_empty() {
4927                            top_platforms.clone()
4928                        } else {
4929                            platforms
4930                        }
4931                    },
4932                    minimum_os_version: json_string(item, "MinimumOSVersion")
4933                        .or_else(|| top_minimum_os_version.clone()),
4934                    architecture: json_string(item, "Architecture"),
4935                    installer_type: json_string(item, "InstallerType"),
4936                    url: json_string(item, "InstallerUrl"),
4937                    sha256: json_string(item, "InstallerSha256"),
4938                    product_code: json_string(item, "ProductCode"),
4939                    locale: json_string(item, "InstallerLocale"),
4940                    scope: json_string(item, "Scope"),
4941                    release_date: json_string(item, "ReleaseDate"),
4942                    package_family_name: json_string(item, "PackageFamilyName"),
4943                    upgrade_code: json_string(item, "UpgradeCode"),
4944                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
4945                    commands: json_string_list(item, "Commands"),
4946                    package_dependencies: json_package_dependencies(item),
4947                })
4948                .collect::<Vec<_>>()
4949        })
4950        .unwrap_or_default();
4951
4952    let manifest = Manifest {
4953        id: package_id.to_owned(),
4954        name,
4955        version: version.to_owned(),
4956        channel: channel.to_owned(),
4957        publisher: json_string(default_locale, "Publisher"),
4958        description: json_string(default_locale, "Description")
4959            .or_else(|| json_string(default_locale, "ShortDescription")),
4960        moniker: json_string(default_locale, "Moniker"),
4961        package_url: json_string(default_locale, "PackageUrl"),
4962        publisher_url: json_string(default_locale, "PublisherUrl"),
4963        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
4964        license: json_string(default_locale, "License"),
4965        license_url: json_string(default_locale, "LicenseUrl"),
4966        privacy_url: json_string(default_locale, "PrivacyUrl"),
4967        author: json_string(default_locale, "Author"),
4968        copyright: json_string(default_locale, "Copyright"),
4969        copyright_url: json_string(default_locale, "CopyrightUrl"),
4970        release_notes: json_string(default_locale, "ReleaseNotes"),
4971        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
4972        tags: json_string_list(default_locale, "Tags"),
4973        agreements: json_agreement_list(default_locale),
4974        package_dependencies: json_package_dependencies(selected),
4975        documentation: json_documentation_list(default_locale),
4976        installers,
4977    };
4978
4979    Ok((
4980        manifest,
4981        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
4982    ))
4983}
4984
4985fn collapse_structured_document(document: &JsonValue) -> JsonValue {
4986    match document {
4987        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
4988            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
4989        }
4990        JsonValue::Array(documents) => merge_manifest_documents(documents),
4991        JsonValue::Object(_) if is_merged_manifest_document(document) => {
4992            merge_manifest_documents(&split_merged_manifest_document(document))
4993        }
4994        _ => document.clone(),
4995    }
4996}
4997
4998fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
4999    documents.iter().map(collapse_structured_document).collect()
5000}
5001
5002fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
5003    let version = documents.iter().find(|document| {
5004        document
5005            .get("ManifestType")
5006            .and_then(JsonValue::as_str)
5007            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
5008    });
5009    let default_locale = documents.iter().find(|document| {
5010        document
5011            .get("ManifestType")
5012            .and_then(JsonValue::as_str)
5013            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
5014    });
5015    let installer = documents.iter().find(|document| {
5016        document
5017            .get("ManifestType")
5018            .and_then(JsonValue::as_str)
5019            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
5020    });
5021
5022    let mut singleton = JsonMap::new();
5023    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
5024    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
5025    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
5026
5027    if !singleton.contains_key("PackageLocale")
5028        && let Some(package_locale) = default_locale
5029            .and_then(|document| json_string(document, "PackageLocale"))
5030            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
5031    {
5032        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
5033    }
5034
5035    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
5036    singleton.insert(
5037        "ManifestVersion".to_owned(),
5038        JsonValue::String(
5039            installer
5040                .and_then(|document| json_string(document, "ManifestVersion"))
5041                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
5042                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
5043                .unwrap_or_else(|| "1.10.0".to_owned()),
5044        ),
5045    );
5046    singleton.remove("DefaultLocale");
5047
5048    JsonValue::Object(singleton)
5049}
5050
5051fn is_merged_manifest_document(document: &JsonValue) -> bool {
5052    document
5053        .get("ManifestType")
5054        .and_then(JsonValue::as_str)
5055        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
5056}
5057
5058fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
5059    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
5060    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
5061    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
5062    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
5063
5064    let version_document = serde_json::json!({
5065        "PackageIdentifier": package_identifier,
5066        "PackageVersion": package_version,
5067        "DefaultLocale": package_locale,
5068        "ManifestType": "version",
5069        "ManifestVersion": manifest_version,
5070    });
5071
5072    let mut default_locale_document = project_manifest_document(
5073        merged,
5074        &[
5075            "PackageIdentifier",
5076            "PackageVersion",
5077            "PackageLocale",
5078            "Publisher",
5079            "PublisherUrl",
5080            "PublisherSupportUrl",
5081            "PrivacyUrl",
5082            "Author",
5083            "PackageName",
5084            "PackageUrl",
5085            "License",
5086            "LicenseUrl",
5087            "Copyright",
5088            "CopyrightUrl",
5089            "ShortDescription",
5090            "Description",
5091            "Moniker",
5092            "Tags",
5093            "Agreements",
5094            "ReleaseNotes",
5095            "ReleaseNotesUrl",
5096            "PurchaseUrl",
5097            "InstallationNotes",
5098            "Documentations",
5099            "Icons",
5100        ],
5101    );
5102    default_locale_document.insert(
5103        "PackageIdentifier".to_owned(),
5104        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
5105    );
5106    default_locale_document.insert(
5107        "PackageVersion".to_owned(),
5108        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
5109    );
5110    default_locale_document.insert(
5111        "PackageLocale".to_owned(),
5112        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
5113    );
5114    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
5115    default_locale_document.insert(
5116        "ManifestVersion".to_owned(),
5117        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
5118    );
5119
5120    let mut installer_document = project_manifest_document(
5121        merged,
5122        &[
5123            "PackageIdentifier",
5124            "PackageVersion",
5125            "Channel",
5126            "InstallerLocale",
5127            "Platform",
5128            "MinimumOSVersion",
5129            "InstallerType",
5130            "NestedInstallerType",
5131            "NestedInstallerFiles",
5132            "Scope",
5133            "InstallModes",
5134            "InstallerSwitches",
5135            "InstallerSuccessCodes",
5136            "ExpectedReturnCodes",
5137            "UpgradeBehavior",
5138            "Commands",
5139            "Protocols",
5140            "FileExtensions",
5141            "Dependencies",
5142            "PackageFamilyName",
5143            "ProductCode",
5144            "Capabilities",
5145            "RestrictedCapabilities",
5146            "Markets",
5147            "InstallerAbortsTerminal",
5148            "ReleaseDate",
5149            "InstallLocationRequired",
5150            "RequireExplicitUpgrade",
5151            "DisplayInstallWarnings",
5152            "UnsupportedOSArchitectures",
5153            "UnsupportedArguments",
5154            "AppsAndFeaturesEntries",
5155            "ElevationRequirement",
5156            "InstallationMetadata",
5157            "DownloadCommandProhibited",
5158            "RepairBehavior",
5159            "ArchiveBinariesDependOnPath",
5160            "Authentication",
5161            "Installers",
5162        ],
5163    );
5164    installer_document.insert(
5165        "PackageIdentifier".to_owned(),
5166        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
5167    );
5168    installer_document.insert(
5169        "PackageVersion".to_owned(),
5170        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
5171    );
5172    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
5173    installer_document.insert(
5174        "ManifestVersion".to_owned(),
5175        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
5176    );
5177
5178    vec![
5179        version_document,
5180        JsonValue::Object(default_locale_document),
5181        JsonValue::Object(installer_document),
5182    ]
5183}
5184
5185fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
5186    let mut result = JsonMap::new();
5187    if let Some(source) = source.as_object() {
5188        for key in keys {
5189            if let Some(value) = source.get(*key) {
5190                result.insert((*key).to_owned(), value.clone());
5191            }
5192        }
5193    }
5194
5195    result
5196}
5197
5198fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
5199    if let Some(source) = source.and_then(JsonValue::as_object) {
5200        for key in keys {
5201            if let Some(value) = source.get(*key) {
5202                target.insert((*key).to_owned(), value.clone());
5203            }
5204        }
5205    }
5206}
5207
5208fn copy_all_manifest_keys_except(
5209    target: &mut JsonMap<String, JsonValue>,
5210    source: Option<&JsonValue>,
5211    excluded_keys: &[&str],
5212) {
5213    if let Some(source) = source.and_then(JsonValue::as_object) {
5214        for (key, value) in source {
5215            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
5216                target.insert(key.clone(), value.clone());
5217            }
5218        }
5219    }
5220}
5221
5222fn build_rest_manifest_documents(
5223    data: &JsonValue,
5224    installer_source: &JsonValue,
5225    default_locale: &JsonValue,
5226    package_id: &str,
5227    version: &str,
5228    channel: &str,
5229) -> JsonValue {
5230    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
5231    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
5232    let manifest_version = json_string(installer_source, "ManifestVersion")
5233        .or_else(|| json_string(default_locale, "ManifestVersion"))
5234        .or_else(|| json_string(data, "ManifestVersion"))
5235        .unwrap_or_else(|| "1.10.0".to_owned());
5236
5237    let mut version_document = JsonMap::new();
5238    version_document.insert(
5239        "PackageIdentifier".to_owned(),
5240        JsonValue::String(package_identifier.clone()),
5241    );
5242    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
5243    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
5244    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
5245    version_document.insert(
5246        "ManifestVersion".to_owned(),
5247        JsonValue::String(manifest_version.clone()),
5248    );
5249
5250    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
5251    default_locale_document.insert(
5252        "PackageIdentifier".to_owned(),
5253        JsonValue::String(package_identifier.clone()),
5254    );
5255    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
5256    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
5257    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
5258    default_locale_document.insert(
5259        "ManifestVersion".to_owned(),
5260        JsonValue::String(manifest_version.clone()),
5261    );
5262
5263    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
5264    installer_document.remove("DefaultLocale");
5265    installer_document.remove("Locales");
5266    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
5267    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
5268    if !channel.is_empty() {
5269        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
5270    }
5271    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
5272    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
5273
5274    let documents = vec![
5275        JsonValue::Object(version_document),
5276        JsonValue::Object(default_locale_document),
5277        JsonValue::Object(installer_document),
5278    ];
5279
5280    merge_manifest_documents(&documents)
5281}
5282
5283fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
5284    let base = installer_defaults(root);
5285    let base_switches = yaml_installer_switches(root);
5286    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
5287        let installers = items
5288            .iter()
5289            .filter_map(YamlValue::as_mapping)
5290            .map(|item| {
5291                let mut merged = base.clone();
5292                merge_yaml_mapping(&mut merged, item);
5293                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
5294                installer_from_yaml(&merged, switches)
5295            })
5296            .collect::<Vec<_>>();
5297        if !installers.is_empty() {
5298            return installers;
5299        }
5300    }
5301
5302    if base.is_empty() {
5303        Vec::new()
5304    } else {
5305        vec![installer_from_yaml(&base, base_switches)]
5306    }
5307}
5308
5309fn installer_defaults(root: &YamlMapping) -> YamlMapping {
5310    let keys = [
5311        "Architecture",
5312        "InstallerType",
5313        "InstallerUrl",
5314        "InstallerSha256",
5315        "ProductCode",
5316        "InstallerLocale",
5317        "Platform",
5318        "MinimumOSVersion",
5319        "Scope",
5320        "ReleaseDate",
5321        "PackageFamilyName",
5322        "UpgradeCode",
5323        "Commands",
5324    ];
5325    let mut defaults = YamlMapping::new();
5326    for key in keys {
5327        if let Some(value) = root.get(YamlValue::from(key)) {
5328            defaults.insert(YamlValue::from(key), value.clone());
5329        }
5330    }
5331    defaults
5332}
5333
5334fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
5335    Installer {
5336        architecture: yaml_string(root, "Architecture"),
5337        installer_type: yaml_string(root, "InstallerType"),
5338        url: yaml_string(root, "InstallerUrl"),
5339        sha256: yaml_string(root, "InstallerSha256"),
5340        product_code: yaml_string(root, "ProductCode"),
5341        locale: yaml_string(root, "InstallerLocale"),
5342        scope: yaml_string(root, "Scope"),
5343        release_date: yaml_string(root, "ReleaseDate"),
5344        package_family_name: yaml_string(root, "PackageFamilyName"),
5345        upgrade_code: yaml_string(root, "UpgradeCode"),
5346        platforms: yaml_string_list(root, "Platform"),
5347        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
5348        switches,
5349        commands: yaml_string_list(root, "Commands"),
5350        package_dependencies: yaml_package_dependencies(root),
5351    }
5352}
5353
5354fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
5355    let mapping = root
5356        .get(YamlValue::from("InstallerSwitches"))
5357        .or_else(|| root.get(YamlValue::from("Switches")))
5358        .and_then(YamlValue::as_mapping);
5359    yaml_installer_switches_from_mapping(mapping)
5360}
5361
5362fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
5363    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
5364
5365    InstallerSwitches {
5366        silent: string("Silent"),
5367        silent_with_progress: string("SilentWithProgress"),
5368        interactive: string("Interactive"),
5369        custom: string("Custom"),
5370        log: string("Log"),
5371        install_location: string("InstallLocation"),
5372    }
5373}
5374
5375fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
5376    yaml_string(root, key).or_else(|| {
5377        root.get(YamlValue::from("DefaultLocale"))
5378            .and_then(YamlValue::as_mapping)
5379            .and_then(|mapping| yaml_string(mapping, key))
5380    })
5381}
5382
5383fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
5384    yaml_string(root, key)
5385}
5386
5387fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
5388    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
5389}
5390
5391fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
5392    match value {
5393        YamlValue::Null => None,
5394        YamlValue::Bool(value) => Some(value.to_string()),
5395        YamlValue::Number(value) => Some(value.to_string()),
5396        YamlValue::String(value) => Some(value.clone()),
5397        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
5398        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
5399    }
5400}
5401
5402fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
5403    root.get(YamlValue::from(key))
5404        .and_then(YamlValue::as_sequence)
5405        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
5406        .unwrap_or_default()
5407}
5408
5409fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
5410    root.get(YamlValue::from("Documentations"))
5411        .and_then(YamlValue::as_sequence)
5412        .map(|items| {
5413            items
5414                .iter()
5415                .filter_map(YamlValue::as_mapping)
5416                .filter_map(|item| {
5417                    let url = yaml_string(item, "DocumentUrl")?;
5418                    Some(Documentation {
5419                        label: yaml_string(item, "DocumentLabel"),
5420                        url,
5421                    })
5422                })
5423                .collect()
5424        })
5425        .unwrap_or_default()
5426}
5427
5428fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
5429    root.get(YamlValue::from("Agreements"))
5430        .and_then(YamlValue::as_sequence)
5431        .map(|items| {
5432            items
5433                .iter()
5434                .filter_map(YamlValue::as_mapping)
5435                .map(|item| PackageAgreement {
5436                    label: yaml_string(item, "AgreementLabel"),
5437                    text: yaml_string(item, "Agreement"),
5438                    url: yaml_string(item, "AgreementUrl"),
5439                })
5440                .collect()
5441        })
5442        .unwrap_or_default()
5443}
5444
5445fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
5446    root.get(YamlValue::from("Dependencies"))
5447        .and_then(YamlValue::as_mapping)
5448        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
5449        .and_then(YamlValue::as_sequence)
5450        .map(|items| {
5451            items
5452                .iter()
5453                .filter_map(YamlValue::as_mapping)
5454                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
5455                .collect()
5456        })
5457        .unwrap_or_default()
5458}
5459
5460fn json_string(value: &JsonValue, key: &str) -> Option<String> {
5461    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
5462}
5463
5464fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
5465    value
5466        .get(key)
5467        .and_then(JsonValue::as_array)
5468        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
5469        .unwrap_or_default()
5470}
5471
5472fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
5473    value
5474        .get("Documentations")
5475        .and_then(JsonValue::as_array)
5476        .map(|items| {
5477            items
5478                .iter()
5479                .filter_map(|item| {
5480                    let url = json_string(item, "DocumentUrl")?;
5481                    Some(Documentation {
5482                        label: json_string(item, "DocumentLabel"),
5483                        url,
5484                    })
5485                })
5486                .collect()
5487        })
5488        .unwrap_or_default()
5489}
5490
5491fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
5492    value
5493        .get("Agreements")
5494        .and_then(JsonValue::as_array)
5495        .map(|items| {
5496            items
5497                .iter()
5498                .map(|item| PackageAgreement {
5499                    label: json_string(item, "AgreementLabel"),
5500                    text: json_string(item, "Agreement"),
5501                    url: json_string(item, "AgreementUrl"),
5502                })
5503                .collect()
5504        })
5505        .unwrap_or_default()
5506}
5507
5508fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
5509    value
5510        .get("Dependencies")
5511        .and_then(JsonValue::as_object)
5512        .and_then(|deps| deps.get("PackageDependencies"))
5513        .and_then(JsonValue::as_array)
5514        .map(|items| {
5515            items
5516                .iter()
5517                .filter_map(|item| json_string(item, "PackageIdentifier"))
5518                .collect()
5519        })
5520        .unwrap_or_default()
5521}
5522
5523fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
5524    let switches = value
5525        .get("InstallerSwitches")
5526        .or_else(|| value.get("Switches"))
5527        .and_then(JsonValue::as_object);
5528
5529    let string = |key: &str| {
5530        switches
5531            .and_then(|switches| switches.get(key))
5532            .and_then(JsonValue::as_str)
5533            .map(str::to_owned)
5534    };
5535
5536    InstallerSwitches {
5537        silent: string("Silent"),
5538        silent_with_progress: string("SilentWithProgress"),
5539        interactive: string("Interactive"),
5540        custom: string("Custom"),
5541        log: string("Log"),
5542        install_location: string("InstallLocation"),
5543    }
5544}
5545
5546fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
5547    for (key, value) in source {
5548        target.insert(key.clone(), value.clone());
5549    }
5550}
5551
5552fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
5553    let reader = Cursor::new(bytes.to_vec());
5554    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
5555    let mut file = archive
5556        .by_name(member_name)
5557        .with_context(|| format!("zip payload missing {member_name}"))?;
5558    let mut output = Vec::new();
5559    file.read_to_end(&mut output)
5560        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
5561    Ok(output)
5562}
5563
5564fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
5565    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
5566        return Ok(output);
5567    }
5568
5569    if !bytes.starts_with(b"CK")
5570        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
5571    {
5572        return decompress_mszyml_payload(&bytes[offset + 2..]);
5573    }
5574
5575    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
5576}
5577
5578fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
5579    let mut decoder = flate2::read::DeflateDecoder::new(payload);
5580    let mut output = String::new();
5581    decoder.read_to_string(&mut output)?;
5582    Ok(output)
5583}
5584
5585fn cache_control_max_age(response: &Response) -> u64 {
5586    response
5587        .headers()
5588        .get(reqwest::header::CACHE_CONTROL)
5589        .and_then(|value| value.to_str().ok())
5590        .and_then(|header| {
5591            header.split(',').find_map(|part| {
5592                let trimmed = part.trim();
5593                trimmed
5594                    .strip_prefix("max-age=")
5595                    .and_then(|value| value.parse::<u64>().ok())
5596            })
5597        })
5598        .unwrap_or(60)
5599}
5600
5601fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
5602    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
5603        let (major, minor) = major_minor(candidate);
5604        server_versions.iter().any(|version| {
5605            let (server_major, server_minor) = major_minor(version);
5606            server_major == major && server_minor == minor
5607        })
5608    })
5609}
5610
5611fn major_minor(version: &str) -> (String, String) {
5612    let mut parts = version.split('.');
5613    (
5614        parts.next().unwrap_or_default().to_owned(),
5615        parts.next().unwrap_or_default().to_owned(),
5616    )
5617}
5618
5619fn default_market() -> String {
5620    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
5621}
5622
5623fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
5624    let requested_locale = query.locale.as_deref();
5625    let requested_architecture = query.installer_architecture.as_deref();
5626    let requested_type = query.installer_type.as_deref();
5627    let requested_scope = query.install_scope.as_deref();
5628    let requested_platform = query.platform.as_deref();
5629    let requested_os_version = query.os_version.as_deref();
5630    let system_architecture = current_architecture();
5631    let system_platform = current_platform();
5632    let current_os_version = current_os_version();
5633
5634    installers
5635        .iter()
5636        .enumerate()
5637        .filter(|(_, installer)| {
5638            installer_matches_requested(
5639                installer,
5640                requested_type,
5641                requested_scope,
5642                requested_platform,
5643                requested_os_version,
5644                system_platform,
5645                current_os_version.as_deref(),
5646            )
5647        })
5648        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
5649        .max_by_key(|(index, installer)| {
5650            (
5651                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
5652                std::cmp::Reverse(*index),
5653            )
5654        })
5655        .map(|(_, installer)| installer)
5656        .cloned()
5657}
5658
5659fn installer_matches_requested(
5660    installer: &Installer,
5661    requested_type: Option<&str>,
5662    requested_scope: Option<&str>,
5663    requested_platform: Option<&str>,
5664    requested_os_version: Option<&str>,
5665    system_platform: Option<&str>,
5666    current_os_version: Option<&str>,
5667) -> bool {
5668    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
5669        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
5670        && installer_matches_platform(installer, requested_platform, system_platform)
5671        && installer_matches_os_version(installer, requested_os_version, current_os_version)
5672}
5673
5674fn installer_matches_platform(
5675    installer: &Installer,
5676    requested_platform: Option<&str>,
5677    system_platform: Option<&str>,
5678) -> bool {
5679    if installer.platforms.is_empty() {
5680        return true;
5681    }
5682
5683    let Some(effective_platform) = requested_platform.or(system_platform) else {
5684        return true;
5685    };
5686
5687    installer
5688        .platforms
5689        .iter()
5690        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
5691}
5692
5693fn installer_matches_os_version(
5694    installer: &Installer,
5695    requested_os_version: Option<&str>,
5696    current_os_version: Option<&str>,
5697) -> bool {
5698    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
5699        return true;
5700    };
5701
5702    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
5703        return true;
5704    };
5705
5706    match (
5707        parse_dotted_version(minimum_os_version),
5708        parse_dotted_version(actual_os_version),
5709    ) {
5710        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
5711        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
5712    }
5713}
5714
5715fn installer_matches_architecture(
5716    installer: &Installer,
5717    requested_architecture: Option<&str>,
5718    system_architecture: &str,
5719) -> bool {
5720    let Some(architecture) = installer.architecture.as_deref() else {
5721        return true;
5722    };
5723
5724    let architecture = architecture.to_ascii_lowercase();
5725    if let Some(requested) = requested_architecture {
5726        return architecture.eq_ignore_ascii_case(requested);
5727    }
5728
5729    preferred_architectures(system_architecture)
5730        .iter()
5731        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
5732}
5733
5734fn installer_rank(
5735    installer: &Installer,
5736    requested_locale: Option<&str>,
5737    requested_architecture: Option<&str>,
5738    system_architecture: &str,
5739) -> (i32, i32, i32) {
5740    let architecture_rank = architecture_rank(
5741        installer.architecture.as_deref(),
5742        requested_architecture.unwrap_or(system_architecture),
5743        requested_architecture.is_some(),
5744        system_architecture,
5745    );
5746    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
5747    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
5748    (architecture_rank, locale_rank, command_rank)
5749}
5750
5751fn architecture_rank(
5752    installer_architecture: Option<&str>,
5753    preferred_architecture: &str,
5754    strict: bool,
5755    system_architecture: &str,
5756) -> i32 {
5757    let Some(value) = installer_architecture else {
5758        return 0;
5759    };
5760    if value.eq_ignore_ascii_case(preferred_architecture) {
5761        return 5;
5762    }
5763    if value.eq_ignore_ascii_case("neutral") {
5764        return 4;
5765    }
5766    if strict {
5767        return -1;
5768    }
5769
5770    preferred_architectures(system_architecture)
5771        .iter()
5772        .rev()
5773        .position(|candidate| value.eq_ignore_ascii_case(candidate))
5774        .and_then(|index| i32::try_from(index).ok())
5775        .and_then(|index| index.checked_add(1))
5776        .unwrap_or(-1)
5777}
5778
5779fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
5780    match system_architecture {
5781        "arm64" => &["arm64", "neutral", "x64", "x86"],
5782        "x64" => &["x64", "neutral", "x86"],
5783        "x86" => &["x86", "neutral"],
5784        _ => &["neutral"],
5785    }
5786}
5787
5788fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
5789    match (installer_locale, requested_locale) {
5790        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
5791        (Some(installer), Some(requested))
5792            if installer
5793                .split('-')
5794                .next()
5795                .zip(requested.split('-').next())
5796                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
5797        {
5798            2
5799        }
5800        (None, Some(_)) => 1,
5801        (Some(_), None) => 1,
5802        (None, None) => 0,
5803        _ => -1,
5804    }
5805}
5806
5807fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
5808    match requested {
5809        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
5810        None => true,
5811    }
5812}
5813
5814fn current_architecture() -> &'static str {
5815    match std::env::consts::ARCH {
5816        "x86_64" => "x64",
5817        "x86" => "x86",
5818        "aarch64" => "arm64",
5819        _ => "neutral",
5820    }
5821}
5822
5823fn current_platform() -> Option<&'static str> {
5824    if cfg!(windows) { Some("Windows.Desktop") } else { None }
5825}
5826
5827#[cfg(windows)]
5828fn current_os_version() -> Option<String> {
5829    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
5830    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
5831    let build: String = key.get_value("CurrentBuildNumber").ok()?;
5832    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
5833    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
5834    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
5835    Some(format!("{major}.{minor}.{build}.{ubr}"))
5836}
5837
5838#[cfg(not(windows))]
5839fn current_os_version() -> Option<String> {
5840    None
5841}
5842
5843fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
5844    let mut parsed = Vec::new();
5845    for component in version.split('.') {
5846        if component.is_empty() {
5847            continue;
5848        }
5849        parsed.push(component.parse().ok()?);
5850    }
5851
5852    if parsed.is_empty() { None } else { Some(parsed) }
5853}
5854
5855fn select_v1_version(
5856    versions: &[V1VersionRow],
5857    requested_version: Option<&str>,
5858    requested_channel: Option<&str>,
5859) -> Result<V1VersionRow> {
5860    let mut ordered = versions.to_vec();
5861    ordered.sort_by(|left, right| {
5862        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
5863    });
5864
5865    if let Some(version) = requested_version {
5866        let channel = requested_channel.unwrap_or_default();
5867        return ordered
5868            .iter()
5869            .find(|candidate| {
5870                candidate.version.eq_ignore_ascii_case(version)
5871                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
5872            })
5873            .cloned()
5874            .ok_or_else(|| anyhow!("requested version {version} was not found"));
5875    }
5876
5877    ordered
5878        .into_iter()
5879        .next()
5880        .ok_or_else(|| anyhow!("package had no versions"))
5881}
5882
5883fn select_v2_version(
5884    versions: &[PackageVersionDataEntry],
5885    requested_version: Option<&str>,
5886) -> Result<PackageVersionDataEntry> {
5887    let mut ordered = versions.to_vec();
5888    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
5889
5890    if let Some(version) = requested_version {
5891        return ordered
5892            .iter()
5893            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
5894            .cloned()
5895            .ok_or_else(|| anyhow!("requested version {version} was not found"));
5896    }
5897
5898    ordered
5899        .into_iter()
5900        .next()
5901        .ok_or_else(|| anyhow!("package had no V2 versions"))
5902}
5903
5904fn select_rest_version<'a>(
5905    versions: &'a [VersionKey],
5906    requested_version: Option<&str>,
5907    requested_channel: Option<&str>,
5908) -> Result<&'a VersionKey> {
5909    if let Some(version) = requested_version {
5910        let channel = requested_channel.unwrap_or_default();
5911        return versions
5912            .iter()
5913            .find(|candidate| {
5914                candidate.version.eq_ignore_ascii_case(version)
5915                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
5916            })
5917            .ok_or_else(|| anyhow!("requested version {version} was not found"));
5918    }
5919
5920    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
5921}
5922
5923fn sort_versions_desc(versions: &mut [VersionKey]) {
5924    versions.sort_by(|left, right| {
5925        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
5926    });
5927}
5928
5929fn compare_version_and_channel(
5930    left_version: &str,
5931    left_channel: &str,
5932    right_version: &str,
5933    right_channel: &str,
5934) -> Ordering {
5935    compare_version(left_version, right_version).then_with(|| {
5936        left_channel
5937            .to_ascii_lowercase()
5938            .cmp(&right_channel.to_ascii_lowercase())
5939    })
5940}
5941
5942fn compare_version(left: &str, right: &str) -> Ordering {
5943    let left_parts = tokenize_version(left);
5944    let right_parts = tokenize_version(right);
5945    let max_len = left_parts.len().max(right_parts.len());
5946
5947    for index in 0..max_len {
5948        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
5949        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
5950        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
5951
5952        let ordering = if let Some((left_number, right_number)) = numeric {
5953            left_number.cmp(&right_number)
5954        } else {
5955            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
5956        };
5957
5958        if ordering != Ordering::Equal {
5959            return ordering;
5960        }
5961    }
5962
5963    Ordering::Equal
5964}
5965
5966fn tokenize_version(value: &str) -> Vec<String> {
5967    let mut parts = Vec::new();
5968    let mut current = String::new();
5969    let mut current_is_digit = None;
5970
5971    for ch in value.chars() {
5972        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
5973            if !current.is_empty() {
5974                parts.push(std::mem::take(&mut current));
5975            }
5976            current_is_digit = None;
5977            continue;
5978        }
5979
5980        let is_digit = ch.is_ascii_digit();
5981        match current_is_digit {
5982            Some(previous) if previous != is_digit => {
5983                parts.push(std::mem::take(&mut current));
5984                current.push(ch);
5985                current_is_digit = Some(is_digit);
5986            }
5987            Some(_) => current.push(ch),
5988            None => {
5989                current.push(ch);
5990                current_is_digit = Some(is_digit);
5991            }
5992        }
5993    }
5994
5995    if !current.is_empty() {
5996        parts.push(current);
5997    }
5998
5999    parts
6000}
6001
6002fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
6003    if !hash_matches(Some(expected_hash), bytes) {
6004        bail!("downloaded file hash did not match expected SHA256");
6005    }
6006    Ok(())
6007}
6008
6009fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
6010    let Some(expected_hash) = expected_hash else {
6011        return Ok(());
6012    };
6013    if ignore_security_hash {
6014        return Ok(());
6015    }
6016
6017    if !hash_matches(Some(expected_hash), bytes) {
6018        let actual = sha256_hex(bytes);
6019        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
6020    }
6021    Ok(())
6022}
6023
6024fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
6025    expected_hash
6026        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
6027        .unwrap_or(true)
6028}
6029
6030fn sha256_hex(bytes: &[u8]) -> String {
6031    let digest = Sha256::digest(bytes);
6032    let mut output = String::with_capacity(digest.len() * 2);
6033    for byte in digest {
6034        output.push_str(&format!("{byte:02X}"));
6035    }
6036    output
6037}
6038
6039fn pins_db_path(app_root: &Path) -> PathBuf {
6040    if uses_packaged_layout(app_root) {
6041        app_root.join("pinning.db")
6042    } else {
6043        app_root.join("pins.db")
6044    }
6045}
6046
6047fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
6048    let mut stmt = conn.prepare("PRAGMA table_info(pin)")?;
6049    let mut has_current_column = false;
6050    let mut has_legacy_column = false;
6051
6052    let column_names = stmt.query_map([], |row| row.get::<_, String>(1))?;
6053    for column_name in column_names {
6054        match column_name?.as_str() {
6055            "type" => has_current_column = true,
6056            "pin_type" => has_legacy_column = true,
6057            _ => {}
6058        }
6059    }
6060
6061    Ok(if has_current_column {
6062        Some("type")
6063    } else if has_legacy_column {
6064        Some("pin_type")
6065    } else {
6066        None
6067    })
6068}
6069
6070fn decode_pin_type(pin_type_int: i64) -> PinType {
6071    match pin_type_int {
6072        4 => PinType::Blocking,
6073        3 => PinType::Gating,
6074        _ => PinType::Pinning,
6075    }
6076}
6077
6078#[cfg(windows)]
6079fn dispatch_installer(
6080    installer_path: &Path,
6081    installer_type: &str,
6082    request: &InstallRequest,
6083    manifest: &Manifest,
6084    installer: &Installer,
6085) -> Result<i32> {
6086    use std::process::Command;
6087
6088    match installer_type {
6089        "msi" | "wix" => {
6090            let mut cmd = Command::new("msiexec");
6091            cmd.args(installer_command_arguments(
6092                installer_type,
6093                request,
6094                manifest,
6095                installer_path,
6096                installer,
6097            ));
6098            let status = cmd.status().context("failed to run msiexec")?;
6099            Ok(status.code().unwrap_or(-1))
6100        }
6101        "msix" | "appx" => {
6102            let mut cmd = Command::new("powershell");
6103            cmd.arg("-NoProfile")
6104                .arg("-Command")
6105                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
6106            let status = cmd.status().context("failed to run Add-AppxPackage")?;
6107            Ok(status.code().unwrap_or(-1))
6108        }
6109        "zip" => {
6110            let target = dirs::data_local_dir()
6111                .unwrap_or_else(|| PathBuf::from("."))
6112                .join("Programs");
6113            fs::create_dir_all(&target)?;
6114            let file = fs::File::open(installer_path)?;
6115            let mut archive = ZipArchive::new(file)?;
6116            archive.extract(&target)?;
6117            Ok(0)
6118        }
6119        // exe, inno, nullsoft, burn, etc.
6120        _ => {
6121            let mut cmd = Command::new(installer_path);
6122            cmd.args(installer_command_arguments(
6123                installer_type,
6124                request,
6125                manifest,
6126                installer_path,
6127                installer,
6128            ));
6129            let status = cmd.status().context("failed to run installer")?;
6130            Ok(status.code().unwrap_or(-1))
6131        }
6132    }
6133}
6134
6135#[cfg(not(windows))]
6136fn dispatch_installer(
6137    _installer_path: &Path,
6138    _installer_type: &str,
6139    _request: &InstallRequest,
6140    _manifest: &Manifest,
6141    _installer: &Installer,
6142) -> Result<i32> {
6143    bail!("Installing packages is only supported on Windows")
6144}
6145
6146#[cfg(any(windows, test))]
6147fn installer_command_arguments(
6148    installer_type: &str,
6149    request: &InstallRequest,
6150    _manifest: &Manifest,
6151    installer_path: &Path,
6152    installer: &Installer,
6153) -> Vec<String> {
6154    if let Some(override_args) = request.override_args.as_deref() {
6155        return split_installer_switches(override_args);
6156    }
6157
6158    let mut args = Vec::new();
6159    match installer_type {
6160        "msi" | "wix" => {
6161            args.push("/i".to_owned());
6162            args.push(installer_path.display().to_string());
6163        }
6164        "msix" | "appx" | "zip" => return Vec::new(),
6165        _ => {}
6166    }
6167
6168    let configured = match request.mode {
6169        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
6170        InstallerMode::SilentWithProgress => installer
6171            .switches
6172            .silent_with_progress
6173            .as_deref()
6174            .or(installer.switches.silent.as_deref()),
6175        InstallerMode::Silent => installer
6176            .switches
6177            .silent
6178            .as_deref()
6179            .or(installer.switches.silent_with_progress.as_deref()),
6180    };
6181
6182    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
6183        args.extend(split_installer_switches(value));
6184    } else {
6185        args.extend(default_installer_arguments(installer_type, request.mode));
6186    }
6187
6188    append_switches(
6189        &mut args,
6190        resolve_template_switch(
6191            installer.switches.log.as_deref(),
6192            default_log_switch(installer_type),
6193            request.log_path.as_ref().map(|path| path.display().to_string()),
6194            "<LOGPATH>",
6195        ),
6196    );
6197    append_switches(&mut args, installer.switches.custom.clone());
6198    append_switches(&mut args, request.custom.clone());
6199    append_switches(
6200        &mut args,
6201        resolve_template_switch(
6202            installer.switches.install_location.as_deref(),
6203            default_install_location_switch(installer_type),
6204            request.install_location.clone(),
6205            "<INSTALLPATH>",
6206        ),
6207    );
6208    args
6209}
6210
6211#[cfg(any(windows, test))]
6212fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
6213    match mode {
6214        InstallerMode::Interactive => Vec::new(),
6215        InstallerMode::SilentWithProgress => match installer_type {
6216            "inno" => vec![
6217                "/SP-".to_owned(),
6218                "/SILENT".to_owned(),
6219                "/SUPPRESSMSGBOXES".to_owned(),
6220                "/NORESTART".to_owned(),
6221            ],
6222            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
6223            "nullsoft" | "nsis" => vec!["/S".to_owned()],
6224            _ => vec!["/SILENT".to_owned()],
6225        },
6226        InstallerMode::Silent => match installer_type {
6227            "inno" => vec![
6228                "/SP-".to_owned(),
6229                "/VERYSILENT".to_owned(),
6230                "/SUPPRESSMSGBOXES".to_owned(),
6231                "/NORESTART".to_owned(),
6232            ],
6233            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
6234            "nullsoft" | "nsis" => vec!["/S".to_owned()],
6235            _ => vec!["/S".to_owned()],
6236        },
6237    }
6238}
6239
6240#[cfg(any(windows, test))]
6241fn default_log_switch(installer_type: &str) -> Option<&'static str> {
6242    match installer_type {
6243        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
6244        "inno" => Some("/LOG=\"<LOGPATH>\""),
6245        _ => None,
6246    }
6247}
6248
6249#[cfg(any(windows, test))]
6250fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
6251    match installer_type {
6252        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
6253        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
6254        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
6255        _ => None,
6256    }
6257}
6258
6259#[cfg(any(windows, test))]
6260fn resolve_template_switch(
6261    manifest_value: Option<&str>,
6262    fallback: Option<&str>,
6263    replacement: Option<String>,
6264    token: &str,
6265) -> Option<String> {
6266    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
6267    let replacement = replacement?;
6268    Some(template.replace(token, &replacement))
6269}
6270
6271#[cfg(any(windows, test))]
6272fn append_switches(args: &mut Vec<String>, value: Option<String>) {
6273    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
6274        args.extend(split_installer_switches(&value));
6275    }
6276}
6277
6278#[cfg(any(windows, test))]
6279fn split_installer_switches(value: &str) -> Vec<String> {
6280    let mut args = Vec::new();
6281    let mut current = String::new();
6282    let mut in_quotes = false;
6283
6284    for ch in value.chars() {
6285        match ch {
6286            '"' => in_quotes = !in_quotes,
6287            c if c.is_whitespace() && !in_quotes => {
6288                if !current.is_empty() {
6289                    args.push(std::mem::take(&mut current));
6290                }
6291            }
6292            _ => current.push(ch),
6293        }
6294    }
6295
6296    if !current.is_empty() {
6297        args.push(current);
6298    }
6299
6300    args
6301}
6302
6303#[cfg(windows)]
6304fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
6305    if request.purge && request.preserve {
6306        bail!("--purge and --preserve cannot be used together.");
6307    }
6308
6309    if installed
6310        .installer_category
6311        .as_deref()
6312        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
6313    {
6314        return uninstall_portable(installed, request);
6315    }
6316
6317    if (request.purge || request.preserve) && !request.force {
6318        bail!("--purge and --preserve are currently only supported for portable packages.");
6319    }
6320
6321    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
6322        return Ok(exit_code);
6323    }
6324
6325    if let Some(exit_code) = try_uninstall_msix(installed)? {
6326        return Ok(exit_code);
6327    }
6328
6329    bail!(
6330        "No uninstall command found for installed package '{}' ({})",
6331        installed.name,
6332        installed.local_id
6333    );
6334}
6335
6336#[cfg(windows)]
6337fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
6338    use std::process::Command;
6339
6340    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
6341    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
6342
6343    let arp_paths = [
6344        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
6345        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
6346    ];
6347
6348    for root in [&hklm, &hkcu] {
6349        for arp_path in &arp_paths {
6350            if let Ok(key) = root.open_subkey(arp_path) {
6351                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
6352                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
6353                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
6354                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
6355                        if !registry_entry_matches_installed_package(
6356                            &subkey_name,
6357                            &display_name,
6358                            product_code.as_deref(),
6359                            installed,
6360                        ) {
6361                            continue;
6362                        }
6363
6364                        if let Some(exit_code) =
6365                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
6366                        {
6367                            return Ok(Some(exit_code));
6368                        }
6369
6370                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
6371                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
6372                            subkey.get_value("UninstallString").ok()
6373                        } else {
6374                            quiet_uninstall_cmd
6375                                .clone()
6376                                .or_else(|| subkey.get_value("UninstallString").ok())
6377                        }
6378                        .context("No uninstall command found in registry")?;
6379
6380                        if is_winget_uninstall_command(&uninstall_cmd)
6381                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
6382                        {
6383                            return Ok(Some(exit_code));
6384                        }
6385
6386                        let mut cmd = Command::new("cmd");
6387                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
6388                        cmd.arg("/C").arg(build_uninstall_command_with_mode(
6389                            &uninstall_cmd,
6390                            request.mode,
6391                            quiet_uninstall_cmd.is_some(),
6392                            log_path.as_deref(),
6393                        ));
6394                        let status = cmd.status().context("failed to run uninstaller")?;
6395                        return Ok(Some(status.code().unwrap_or(-1)));
6396                    }
6397                }
6398            }
6399        }
6400    }
6401
6402    Ok(None)
6403}
6404
6405#[cfg(windows)]
6406fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
6407    use std::process::Command;
6408
6409    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
6410        return Ok(None);
6411    }
6412
6413    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
6414    let mut cmd = Command::new("powershell");
6415    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
6416        package_full_name,
6417        &installed.package_family_names,
6418    ));
6419    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
6420    Ok(Some(status.code().unwrap_or(-1)))
6421}
6422
6423#[cfg(windows)]
6424fn arp_subkey_name(local_id: &str) -> Option<&str> {
6425    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
6426}
6427
6428#[cfg(windows)]
6429fn registry_entry_matches_installed_package(
6430    subkey_name: &str,
6431    display_name: &str,
6432    product_code: Option<&str>,
6433    installed: &ListMatch,
6434) -> bool {
6435    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
6436        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
6437    {
6438        return true;
6439    }
6440
6441    if installed.product_codes.iter().any(|code| {
6442        code.eq_ignore_ascii_case(subkey_name)
6443            || product_code
6444                .map(|value| code.eq_ignore_ascii_case(value))
6445                .unwrap_or(false)
6446    }) {
6447        return true;
6448    }
6449
6450    display_name.eq_ignore_ascii_case(&installed.name)
6451}
6452
6453#[cfg(all(windows, test))]
6454fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
6455    build_uninstall_command_with_mode(
6456        uninstall_cmd,
6457        if silent {
6458            InstallerMode::Silent
6459        } else {
6460            InstallerMode::Interactive
6461        },
6462        has_quiet_command,
6463        None,
6464    )
6465}
6466
6467#[cfg(windows)]
6468fn build_uninstall_command_with_mode(
6469    uninstall_cmd: &str,
6470    mode: InstallerMode,
6471    has_quiet_command: bool,
6472    log_path: Option<&str>,
6473) -> String {
6474    let uninstall_cmd = log_path
6475        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
6476        .unwrap_or_else(|| uninstall_cmd.to_owned());
6477    if mode == InstallerMode::Interactive || has_quiet_command {
6478        return uninstall_cmd;
6479    }
6480
6481    let lower = uninstall_cmd.to_ascii_lowercase();
6482    if is_winget_uninstall_command_lower(&lower) {
6483        return uninstall_cmd;
6484    }
6485    if lower.contains("/quiet")
6486        || lower.contains("/passive")
6487        || lower.contains("/verysilent")
6488        || lower.contains("/silent")
6489        || lower.contains(" /s")
6490    {
6491        return uninstall_cmd;
6492    }
6493
6494    format!("{uninstall_cmd} /S")
6495}
6496
6497#[cfg(windows)]
6498fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
6499    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
6500        return Ok(None);
6501    };
6502
6503    let exit_code = run_winget_uninstall(&args)?;
6504    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
6505        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
6506            return Ok(Some(exit_code));
6507        };
6508
6509        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
6510    }
6511
6512    Ok(Some(exit_code))
6513}
6514
6515#[cfg(windows)]
6516fn run_winget_uninstall(args: &[String]) -> Result<i32> {
6517    use std::process::Command;
6518
6519    let status = Command::new("winget")
6520        .args(args)
6521        .status()
6522        .context("failed to run delegated winget uninstall")?;
6523    Ok(status.code().unwrap_or(-1))
6524}
6525
6526#[cfg(windows)]
6527fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
6528    build_winget_uninstall_arguments_with_scope(installed, request, true)
6529}
6530
6531#[cfg(windows)]
6532fn build_winget_uninstall_arguments_with_scope(
6533    installed: &ListMatch,
6534    request: &UninstallRequest,
6535    include_scope: bool,
6536) -> Option<Vec<String>> {
6537    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
6538        return None;
6539    }
6540
6541    let mut args = vec![
6542        "uninstall".to_owned(),
6543        "--id".to_owned(),
6544        installed.id.clone(),
6545        "--exact".to_owned(),
6546        "--disable-interactivity".to_owned(),
6547    ];
6548
6549    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
6550        args.push("--source".to_owned());
6551        args.push(source_name.to_owned());
6552    }
6553
6554    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
6555        args.push("--scope".to_owned());
6556        args.push(scope.to_owned());
6557    }
6558
6559    if request.mode == InstallerMode::Silent {
6560        args.push("--silent".to_owned());
6561    }
6562
6563    Some(args)
6564}
6565
6566#[cfg(windows)]
6567fn is_winget_uninstall_command(command: &str) -> bool {
6568    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
6569}
6570
6571#[cfg(windows)]
6572fn is_winget_uninstall_command_lower(command: &str) -> bool {
6573    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
6574}
6575
6576#[cfg(windows)]
6577fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
6578    let full_name_literal = package_full_name
6579        .map(|value| format!("'{}'", value.replace('\'', "''")))
6580        .unwrap_or_else(|| "$null".to_owned());
6581    let family_names_literal = if package_family_names.is_empty() {
6582        "@()".to_owned()
6583    } else {
6584        format!(
6585            "@({})",
6586            package_family_names
6587                .iter()
6588                .map(|value| format!("'{}'", value.replace('\'', "''")))
6589                .collect::<Vec<_>>()
6590                .join(", ")
6591        )
6592    };
6593
6594    format!(
6595        "$fullName = {full_name_literal}; \
6596         $familyNames = {family_names_literal}; \
6597         $targets = Get-AppxPackage | Where-Object {{ \
6598         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
6599         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
6600         if (-not $targets) {{ exit 1 }}; \
6601         $targets | Remove-AppxPackage"
6602    )
6603}
6604
6605#[cfg(windows)]
6606fn try_run_msi_uninstall(
6607    installed: &ListMatch,
6608    subkey_name: &str,
6609    product_code: Option<&str>,
6610    request: &UninstallRequest,
6611) -> Result<Option<i32>> {
6612    use std::process::Command;
6613
6614    let uninstall_code = installed
6615        .product_codes
6616        .iter()
6617        .map(String::as_str)
6618        .chain(product_code)
6619        .chain(Some(subkey_name))
6620        .find(|value| is_product_code_like(value));
6621
6622    let Some(uninstall_code) = uninstall_code else {
6623        return Ok(None);
6624    };
6625
6626    let mut cmd = Command::new("msiexec");
6627    cmd.arg("/x").arg(uninstall_code);
6628    match request.mode {
6629        InstallerMode::Interactive => {}
6630        InstallerMode::SilentWithProgress => {
6631            cmd.arg("/passive").arg("/norestart");
6632        }
6633        InstallerMode::Silent => {
6634            cmd.arg("/quiet").arg("/norestart");
6635        }
6636    }
6637    if let Some(log_path) = &request.log_path {
6638        cmd.arg("/log").arg(log_path);
6639    }
6640
6641    let status = cmd.status().context("failed to run msiexec uninstall")?;
6642    Ok(Some(status.code().unwrap_or(-1)))
6643}
6644
6645#[cfg(windows)]
6646fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
6647    let Some(location) = installed.install_location.as_deref() else {
6648        if request.force {
6649            return Ok(0);
6650        }
6651        bail!(
6652            "Portable package '{}' does not expose an install location.",
6653            installed.name
6654        );
6655    };
6656
6657    if request.preserve {
6658        return Ok(0);
6659    }
6660
6661    let path = Path::new(location);
6662    if path.is_dir() {
6663        fs::remove_dir_all(path)?;
6664        return Ok(0);
6665    }
6666    if path.is_file() {
6667        fs::remove_file(path)?;
6668        return Ok(0);
6669    }
6670    if request.force {
6671        return Ok(0);
6672    }
6673
6674    bail!("Portable package location not found: {location}")
6675}
6676
6677#[cfg(windows)]
6678fn is_product_code_like(value: &str) -> bool {
6679    value.starts_with('{') && value.ends_with('}')
6680}
6681
6682#[cfg(not(windows))]
6683fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
6684    bail!("Uninstalling packages is only supported on Windows")
6685}
6686
6687#[cfg(test)]
6688mod tests {
6689    use std::io::Write;
6690    use std::path::{Path, PathBuf};
6691
6692    use flate2::Compression;
6693    use flate2::write::DeflateEncoder;
6694
6695    use super::*;
6696
6697    fn temp_app_root(label: &str) -> PathBuf {
6698        std::env::temp_dir().join(format!(
6699            "pinget-rs-tests-{label}-{}",
6700            Utc::now().timestamp_nanos_opt().unwrap_or_default()
6701        ))
6702    }
6703
6704    #[test]
6705    fn repository_options_capture_custom_host_settings() {
6706        let options =
6707            RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test")).with_user_agent("pinget-rs-tests/1.0");
6708
6709        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
6710        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
6711    }
6712
6713    #[test]
6714    fn dependency_install_requests_are_silent_and_not_forced() {
6715        let mut parent = InstallRequest::new(PackageQuery {
6716            source: Some("winget".to_owned()),
6717            ..PackageQuery::default()
6718        });
6719        parent.mode = InstallerMode::SilentWithProgress;
6720        parent.force = true;
6721        parent.accept_package_agreements = true;
6722        parent.ignore_security_hash = true;
6723        parent.dependency_source = Some("dependencies".to_owned());
6724
6725        let dependency =
6726            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
6727
6728        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
6729        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
6730        assert_eq!(dependency.mode, InstallerMode::Silent);
6731        assert!(!dependency.force);
6732        assert!(dependency.no_upgrade);
6733        assert!(dependency.accept_package_agreements);
6734        assert!(dependency.ignore_security_hash);
6735    }
6736
6737    #[test]
6738    fn storage_paths_use_configured_app_root() {
6739        let app_root = PathBuf::from(r"C:\temp\pinget-test");
6740        let source = SourceRecord {
6741            name: "winget/test".to_owned(),
6742            kind: SourceKind::PreIndexed,
6743            arg: "https://example.com/cache".to_owned(),
6744            identifier: "Test.Source".to_owned(),
6745            trust_level: "None".to_owned(),
6746            explicit: false,
6747            priority: 0,
6748            last_update: None,
6749            source_version: None,
6750        };
6751
6752        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
6753        assert_eq!(
6754            source_state_dir(&app_root, &source),
6755            app_root.join("sources").join("winget_test")
6756        );
6757        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
6758    }
6759
6760    #[cfg(windows)]
6761    #[test]
6762    fn packaged_layout_paths_resolve_from_packaged_app_root() {
6763        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
6764        let app_root = local_app_data
6765            .join("Packages")
6766            .join(PACKAGED_FAMILY_NAME)
6767            .join("LocalState");
6768        assert!(uses_packaged_layout(&app_root));
6769        assert!(
6770            user_settings_path(&app_root)
6771                .display()
6772                .to_string()
6773                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
6774        );
6775        assert!(
6776            packaged_file_cache_root(&app_root)
6777                .display()
6778                .to_string()
6779                .ends_with(&format!(
6780                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
6781                    PACKAGED_FAMILY_NAME
6782                ))
6783        );
6784    }
6785
6786    #[cfg(windows)]
6787    #[test]
6788    fn default_app_root_outside_package_avoids_packaged_layout() {
6789        // Tests run without AppX package identity, so the default app root must not
6790        // resolve to the WinGet packaged LocalState — that location requires a
6791        // brokered/elevated writer for its secure-settings stream.
6792        let prior = std::env::var_os("PINGET_APPROOT");
6793        // SAFETY: tests in this module are not run concurrently with other env mutators.
6794        unsafe { std::env::remove_var("PINGET_APPROOT") };
6795
6796        let result = default_app_root();
6797
6798        if let Some(prior) = prior {
6799            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
6800            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
6801        }
6802
6803        let app_root = result.expect("default app root");
6804        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
6805        assert!(
6806            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
6807            "unexpected fallback app root: {app_root:?}"
6808        );
6809    }
6810
6811    #[test]
6812    fn packaged_source_stream_overlays_defaults_and_metadata() {
6813        let store = parse_packaged_source_store(
6814            Some(
6815                "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",
6816            ),
6817            Some(
6818                "Sources:\n  - Name: corp\n    LastUpdate: 1700000000\n    SourceVersion: 1.2.3\n",
6819            ),
6820        )
6821        .expect("packaged store");
6822
6823        assert!(!store.sources.iter().any(|source| source.name == "winget"));
6824        let corp = store
6825            .sources
6826            .iter()
6827            .find(|source| source.name == "corp")
6828            .expect("corp source");
6829        assert_eq!(corp.kind, SourceKind::Rest);
6830        assert_eq!(corp.identifier, "Contoso.Rest");
6831        assert_eq!(corp.trust_level, "Trusted");
6832        assert!(corp.explicit);
6833        assert_eq!(corp.priority, 7);
6834        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
6835        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
6836    }
6837
6838    #[test]
6839    fn system_winget_export_parses_json_lines() {
6840        let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
6841{"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"}
6842"#;
6843
6844        let sources = parse_system_winget_source_export(output).expect("source export");
6845
6846        assert_eq!(
6847            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
6848            vec!["contoso", "contoso-cache"]
6849        );
6850        assert_eq!(sources[0].kind, SourceKind::Rest);
6851        assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
6852        assert_eq!(sources[0].identifier, "api.contoso.test");
6853        assert_eq!(sources[0].trust_level, "Trusted");
6854        assert!(!sources[0].explicit);
6855        assert_eq!(sources[1].kind, SourceKind::PreIndexed);
6856        assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
6857        assert!(sources[1].explicit);
6858    }
6859
6860    #[test]
6861    fn system_winget_export_parses_wrapped_sources() {
6862        let output = r#"{
6863  "Sources": [
6864    {
6865      "Name": "corp",
6866      "Type": "Microsoft.Rest",
6867      "Arg": "https://packages.contoso.test/api",
6868      "Identifier": "Contoso.Rest",
6869      "TrustLevel": "Trusted",
6870      "Explicit": true,
6871      "Priority": 9
6872    }
6873  ]
6874}"#;
6875
6876        let sources = parse_system_winget_source_export(output).expect("source export");
6877        let source = sources.first().expect("source");
6878        assert_eq!(sources.len(), 1);
6879        assert_eq!(source.name, "corp");
6880        assert_eq!(source.kind, SourceKind::Rest);
6881        assert_eq!(source.identifier, "Contoso.Rest");
6882        assert_eq!(source.trust_level, "Trusted");
6883        assert!(source.explicit);
6884        assert_eq!(source.priority, 9);
6885    }
6886
6887    #[test]
6888    fn system_winget_source_store_detects_secure_settings_stub() {
6889        assert!(is_secure_settings_stub(Some(
6890            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
6891        )));
6892        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
6893    }
6894
6895    #[test]
6896    fn system_winget_source_store_builds_add_arguments() {
6897        let args = build_winget_source_add_arguments(
6898            "corp",
6899            "https://packages.contoso.test/api",
6900            SourceKind::Rest,
6901            "Trusted",
6902            true,
6903        );
6904
6905        assert_eq!(
6906            args,
6907            vec![
6908                "source",
6909                "add",
6910                "--name",
6911                "corp",
6912                "--arg",
6913                "https://packages.contoso.test/api",
6914                "--type",
6915                "Microsoft.Rest",
6916                "--disable-interactivity",
6917                "--trust-level",
6918                "trusted",
6919                "--explicit",
6920            ]
6921        );
6922    }
6923
6924    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
6925        Ok(WingetSourceCommandResult {
6926            exit_code: 0,
6927            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
6928"#
6929            .to_owned(),
6930            stderr: String::new(),
6931        })
6932    }
6933
6934    #[test]
6935    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
6936        let original_runner = {
6937            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
6938            let original = *runner;
6939            *runner = fake_system_winget_source_export;
6940            original
6941        };
6942
6943        let result = load_packaged_store_from_streams(
6944            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
6945            None,
6946        );
6947
6948        {
6949            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
6950            *runner = original_runner;
6951        }
6952
6953        let store = result.expect("packaged store").expect("packaged store");
6954        let source = store.sources.first().expect("source");
6955        assert_eq!(store.sources.len(), 1);
6956        assert_eq!(source.name, "contoso");
6957        assert_eq!(source.kind, SourceKind::Rest);
6958    }
6959
6960    #[test]
6961    fn source_metadata_and_named_reset_round_trip() {
6962        let app_root = temp_app_root("source-reset");
6963        let result = (|| -> Result<()> {
6964            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
6965            repository.add_source_with_metadata(
6966                "test",
6967                "https://example.com/test",
6968                SourceKind::Rest,
6969                Some("trusted"),
6970                true,
6971                4,
6972            )?;
6973
6974            repository.edit_source("test", Some(false), None)?;
6975            let edited = repository
6976                .list_sources()
6977                .into_iter()
6978                .find(|source| source.name == "test")
6979                .expect("source");
6980            assert_eq!(edited.trust_level, "Trusted");
6981            assert!(!edited.explicit);
6982            assert_eq!(edited.priority, 4);
6983
6984            repository.reset_source("test")?;
6985            let reset = repository
6986                .list_sources()
6987                .into_iter()
6988                .find(|source| source.name == "test")
6989                .expect("source");
6990            assert_eq!(reset.trust_level, "Trusted");
6991            assert!(!reset.explicit);
6992            assert_eq!(reset.priority, 4);
6993            assert!(reset.last_update.is_none());
6994            assert!(reset.source_version.is_none());
6995            Ok(())
6996        })();
6997
6998        let _ = fs::remove_dir_all(&app_root);
6999        result.expect("source metadata");
7000    }
7001
7002    #[test]
7003    fn explicit_sources_are_skipped_by_default_search_resolution() {
7004        let app_root = temp_app_root("source-resolution");
7005        let result = (|| -> Result<()> {
7006            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
7007            repository.add_source_with_metadata(
7008                "explicit-test",
7009                "https://example.com/explicit",
7010                SourceKind::Rest,
7011                Some("trusted"),
7012                true,
7013                10,
7014            )?;
7015
7016            let indexes = repository.resolve_source_indexes(None)?;
7017            let names: Vec<_> = indexes
7018                .into_iter()
7019                .map(|index| repository.list_sources()[index].name.clone())
7020                .collect();
7021            assert!(!names.iter().any(|name| name == "explicit-test"));
7022            Ok(())
7023        })();
7024
7025        let _ = fs::remove_dir_all(&app_root);
7026        result.expect("source resolution");
7027    }
7028
7029    #[test]
7030    fn user_and_admin_settings_round_trip() {
7031        let app_root = temp_app_root("settings");
7032        let result = (|| -> Result<()> {
7033            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
7034            repository.set_user_settings(
7035                &serde_json::json!({
7036                    "visual": {
7037                        "progressBar": "retro"
7038                    }
7039                }),
7040                false,
7041            )?;
7042            repository.set_user_settings(
7043                &serde_json::json!({
7044                    "experimentalFeatures": {
7045                        "directMSI": true
7046                    }
7047                }),
7048                true,
7049            )?;
7050
7051            let user_settings = repository.get_user_settings()?;
7052            assert_eq!(
7053                user_settings["visual"]["progressBar"],
7054                serde_json::Value::String("retro".to_owned())
7055            );
7056            assert_eq!(
7057                user_settings["experimentalFeatures"]["directMSI"],
7058                serde_json::Value::Bool(true)
7059            );
7060            assert!(repository.test_user_settings(
7061                &serde_json::json!({
7062                    "experimentalFeatures": {
7063                        "directMSI": true
7064                    }
7065                }),
7066                true
7067            )?);
7068
7069            repository.set_admin_setting("LocalManifestFiles", true)?;
7070            repository.set_admin_setting("InstallerHashOverride", true)?;
7071            let admin_settings = repository.get_admin_settings()?;
7072            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
7073            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
7074
7075            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
7076            let reset_one = repository.get_admin_settings()?;
7077            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
7078
7079            repository.reset_admin_setting(None, true)?;
7080            let reset_all = repository.get_admin_settings()?;
7081            for name in Repository::supported_admin_settings() {
7082                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
7083            }
7084            Ok(())
7085        })();
7086
7087        let _ = fs::remove_dir_all(&app_root);
7088        result.expect("settings");
7089    }
7090
7091    #[test]
7092    fn show_result_structured_document_is_manifest_oriented() {
7093        let result = ShowResult {
7094            package: SearchMatch {
7095                source_name: "winget".to_owned(),
7096                source_kind: SourceKind::PreIndexed,
7097                id: "Test.Package".to_owned(),
7098                name: "Test Package".to_owned(),
7099                moniker: Some("testpkg".to_owned()),
7100                version: Some("1.2.3".to_owned()),
7101                channel: Some("stable".to_owned()),
7102                match_criteria: Some("Id".to_owned()),
7103            },
7104            manifest: Manifest {
7105                id: "Test.Package".to_owned(),
7106                name: "Test Package".to_owned(),
7107                version: "1.2.3".to_owned(),
7108                channel: "stable".to_owned(),
7109                publisher: Some("Contoso".to_owned()),
7110                description: Some("Structured output".to_owned()),
7111                moniker: Some("testpkg".to_owned()),
7112                package_url: None,
7113                publisher_url: None,
7114                publisher_support_url: None,
7115                license: None,
7116                license_url: None,
7117                privacy_url: None,
7118                author: None,
7119                copyright: None,
7120                copyright_url: None,
7121                release_notes: None,
7122                release_notes_url: None,
7123                tags: vec!["utility".to_owned()],
7124                agreements: Vec::new(),
7125                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
7126                documentation: vec![Documentation {
7127                    label: Some("Docs".to_owned()),
7128                    url: "https://example.test/docs".to_owned(),
7129                }],
7130                installers: vec![Installer {
7131                    architecture: Some("x64".to_owned()),
7132                    installer_type: Some("msix".to_owned()),
7133                    url: Some("https://example.test/Test.Package.msix".to_owned()),
7134                    sha256: Some("ABC123".to_owned()),
7135                    product_code: None,
7136                    locale: Some("en-US".to_owned()),
7137                    scope: Some("machine".to_owned()),
7138                    release_date: None,
7139                    package_family_name: None,
7140                    upgrade_code: None,
7141                    platforms: Vec::new(),
7142                    minimum_os_version: None,
7143                    switches: InstallerSwitches {
7144                        silent: Some("/quiet".to_owned()),
7145                        ..InstallerSwitches::default()
7146                    },
7147                    commands: vec!["testpkg".to_owned()],
7148                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
7149                }],
7150            },
7151            selected_installer: Some(Installer {
7152                architecture: Some("x64".to_owned()),
7153                installer_type: Some("msix".to_owned()),
7154                url: Some("https://example.test/Test.Package.msix".to_owned()),
7155                sha256: Some("ABC123".to_owned()),
7156                product_code: None,
7157                locale: Some("en-US".to_owned()),
7158                scope: Some("machine".to_owned()),
7159                release_date: None,
7160                package_family_name: None,
7161                upgrade_code: None,
7162                platforms: Vec::new(),
7163                minimum_os_version: None,
7164                switches: InstallerSwitches {
7165                    silent: Some("/quiet".to_owned()),
7166                    ..InstallerSwitches::default()
7167                },
7168                commands: vec!["testpkg".to_owned()],
7169                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
7170            }),
7171            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
7172            warnings: vec!["cache warmed".to_owned()],
7173            manifest_documents: JsonValue::Array(vec![
7174                serde_json::json!({
7175                    "PackageIdentifier": "Test.Package",
7176                    "PackageVersion": "1.2.3",
7177                    "DefaultLocale": "en-US",
7178                    "ManifestType": "version",
7179                    "ManifestVersion": "1.10.0"
7180                }),
7181                serde_json::json!({
7182                    "PackageIdentifier": "Test.Package",
7183                    "PackageVersion": "1.2.3",
7184                    "PackageLocale": "en-US",
7185                    "PackageName": "Test Package",
7186                    "Publisher": "Example",
7187                    "License": "MIT",
7188                    "ShortDescription": "Structured output",
7189                    "ManifestType": "defaultLocale",
7190                    "ManifestVersion": "1.10.0"
7191                }),
7192                serde_json::json!({
7193                    "PackageIdentifier": "Test.Package",
7194                    "PackageVersion": "1.2.3",
7195                    "ManifestType": "installer",
7196                    "ManifestVersion": "1.10.0",
7197                    "Installers": [
7198                        {
7199                            "Architecture": "x64",
7200                            "InstallerType": "msix",
7201                            "InstallerUrl": "https://example.test/Test.Package.msix",
7202                            "InstallerSha256": "ABC123",
7203                            "Commands": ["testpkg"],
7204                            "InstallerSwitches": { "Silent": "/quiet" },
7205                            "Dependencies": {
7206                                "PackageDependencies": [
7207                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
7208                                ]
7209                            }
7210                        }
7211                    ]
7212                }),
7213            ]),
7214        };
7215
7216        let document = result.structured_document();
7217
7218        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
7219        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
7220        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
7221        assert_eq!(
7222            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
7223            Some("Microsoft.VCRedist.2015+.x64")
7224        );
7225        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
7226        assert_eq!(
7227            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
7228            Some("/quiet")
7229        );
7230    }
7231
7232    #[test]
7233    fn parse_yaml_manifest_bundle_returns_singleton_document() {
7234        let yaml = r#"
7235PackageIdentifier: Test.Package
7236PackageVersion: 1.2.3
7237DefaultLocale: en-US
7238ManifestType: version
7239ManifestVersion: 1.10.0
7240---
7241PackageIdentifier: Test.Package
7242PackageVersion: 1.2.3
7243PackageLocale: en-US
7244PackageName: Test Package
7245Publisher: Example
7246License: MIT
7247ShortDescription: Structured output
7248ManifestType: defaultLocale
7249ManifestVersion: 1.10.0
7250---
7251PackageIdentifier: Test.Package
7252PackageVersion: 1.2.3
7253ManifestType: installer
7254ManifestVersion: 1.10.0
7255Installers:
7256  - Architecture: x64
7257    InstallerType: exe
7258    InstallerUrl: https://example.test/Test.Package.exe
7259    InstallerSha256: ABC123
7260"#;
7261
7262        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
7263
7264        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
7265        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
7266        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
7267    }
7268
7269    #[test]
7270    fn collapse_structured_documents_returns_plural_show_documents() {
7271        let documents = collapse_structured_documents(&[
7272            JsonValue::Array(vec![
7273                serde_json::json!({
7274                    "PackageIdentifier": "Test.Package.One",
7275                    "PackageVersion": "1.0.0",
7276                    "DefaultLocale": "en-US",
7277                    "ManifestType": "version",
7278                    "ManifestVersion": "1.10.0"
7279                }),
7280                serde_json::json!({
7281                    "PackageIdentifier": "Test.Package.One",
7282                    "PackageVersion": "1.0.0",
7283                    "PackageLocale": "en-US",
7284                    "PackageName": "Test Package One",
7285                    "ManifestType": "defaultLocale",
7286                    "ManifestVersion": "1.10.0"
7287                }),
7288                serde_json::json!({
7289                    "PackageIdentifier": "Test.Package.One",
7290                    "PackageVersion": "1.0.0",
7291                    "ManifestType": "installer",
7292                    "ManifestVersion": "1.10.0",
7293                    "Installers": [
7294                        {
7295                            "Architecture": "x64",
7296                            "InstallerType": "exe",
7297                            "InstallerUrl": "https://example.test/one.exe",
7298                            "InstallerSha256": "ABC123"
7299                        }
7300                    ]
7301                }),
7302            ]),
7303            serde_json::json!({
7304                "PackageIdentifier": "Test.Package.Two",
7305                "PackageVersion": "2.0.0",
7306                "PackageLocale": "en-US",
7307                "PackageName": "Test Package Two",
7308                "ManifestType": "singleton",
7309                "ManifestVersion": "1.12.0"
7310            }),
7311        ]);
7312
7313        assert_eq!(documents.len(), 2);
7314        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
7315        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
7316        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
7317        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
7318        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
7319    }
7320
7321    #[test]
7322    fn unsupported_action_result_marks_noop_and_warning() {
7323        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
7324
7325        assert!(result.success);
7326        assert!(result.no_op);
7327        assert_eq!(result.exit_code, 0);
7328        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
7329    }
7330
7331    #[test]
7332    fn create_install_no_op_result_honors_no_upgrade() {
7333        let mut request = InstallRequest::new(PackageQuery {
7334            id: Some("Contoso.App".to_owned()),
7335            ..PackageQuery::default()
7336        });
7337        request.no_upgrade = true;
7338
7339        let manifest = Manifest {
7340            id: "Contoso.App".to_owned(),
7341            name: "Contoso App".to_owned(),
7342            version: "2.0.0".to_owned(),
7343            channel: String::new(),
7344            publisher: None,
7345            description: None,
7346            moniker: None,
7347            package_url: None,
7348            publisher_url: None,
7349            publisher_support_url: None,
7350            license: None,
7351            license_url: None,
7352            privacy_url: None,
7353            author: None,
7354            copyright: None,
7355            copyright_url: None,
7356            release_notes: None,
7357            release_notes_url: None,
7358            tags: Vec::new(),
7359            agreements: Vec::new(),
7360            package_dependencies: Vec::new(),
7361            documentation: Vec::new(),
7362            installers: Vec::new(),
7363        };
7364        let existing = ListMatch {
7365            name: "Contoso App".to_owned(),
7366            id: "Contoso.App".to_owned(),
7367            local_id: "Contoso.App".to_owned(),
7368            installed_version: "1.0.0".to_owned(),
7369            available_version: Some("2.0.0".to_owned()),
7370            source_name: Some("winget".to_owned()),
7371            publisher: None,
7372            scope: None,
7373            installer_category: None,
7374            install_location: None,
7375            package_family_names: Vec::new(),
7376            product_codes: Vec::new(),
7377            upgrade_codes: Vec::new(),
7378        };
7379
7380        let result =
7381            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
7382
7383        assert!(result.success);
7384        assert!(result.no_op);
7385        assert_eq!(result.version, "1.0.0");
7386        assert_eq!(
7387            result.warnings,
7388            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
7389        );
7390    }
7391
7392    #[test]
7393    fn create_install_no_op_result_skips_reinstall_when_current() {
7394        let request = InstallRequest::new(PackageQuery {
7395            id: Some("Contoso.App".to_owned()),
7396            ..PackageQuery::default()
7397        });
7398
7399        let manifest = Manifest {
7400            id: "Contoso.App".to_owned(),
7401            name: "Contoso App".to_owned(),
7402            version: "2.0.0".to_owned(),
7403            channel: String::new(),
7404            publisher: None,
7405            description: None,
7406            moniker: None,
7407            package_url: None,
7408            publisher_url: None,
7409            publisher_support_url: None,
7410            license: None,
7411            license_url: None,
7412            privacy_url: None,
7413            author: None,
7414            copyright: None,
7415            copyright_url: None,
7416            release_notes: None,
7417            release_notes_url: None,
7418            tags: Vec::new(),
7419            agreements: Vec::new(),
7420            package_dependencies: Vec::new(),
7421            documentation: Vec::new(),
7422            installers: Vec::new(),
7423        };
7424        let existing = ListMatch {
7425            name: "Contoso App".to_owned(),
7426            id: "Contoso.App".to_owned(),
7427            local_id: "Contoso.App".to_owned(),
7428            installed_version: "2.0.0".to_owned(),
7429            available_version: Some("2.0.0".to_owned()),
7430            source_name: Some("winget".to_owned()),
7431            publisher: None,
7432            scope: None,
7433            installer_category: None,
7434            install_location: None,
7435            package_family_names: Vec::new(),
7436            product_codes: Vec::new(),
7437            upgrade_codes: Vec::new(),
7438        };
7439
7440        let result =
7441            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
7442
7443        assert!(result.success);
7444        assert!(result.no_op);
7445        assert_eq!(result.version, "2.0.0");
7446        assert_eq!(
7447            result.warnings,
7448            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
7449        );
7450    }
7451
7452    #[test]
7453    fn collapse_structured_document_projects_merged_manifest() {
7454        let document = collapse_structured_document(&serde_json::json!({
7455            "PackageIdentifier": "Test.Package",
7456            "PackageVersion": "1.2.3",
7457            "PackageLocale": "en-US",
7458            "PackageName": "Test Package",
7459            "Publisher": "Example",
7460            "InstallerType": "exe",
7461            "Installers": [
7462                {
7463                    "Architecture": "x64",
7464                    "InstallerUrl": "https://example.test/Test.Package.exe",
7465                    "InstallerSha256": "ABC123"
7466                }
7467            ],
7468            "ManifestType": "merged",
7469            "ManifestVersion": "1.10.0"
7470        }));
7471
7472        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
7473        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
7474        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
7475        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
7476        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
7477    }
7478
7479    #[test]
7480    fn parses_fixture_manifest() {
7481        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
7482            .join("tests")
7483            .join("fixtures")
7484            .join("ManifestV1_28-Singleton.yaml");
7485        let bytes = fs::read(root).expect("fixture bytes");
7486        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
7487        assert_eq!(manifest.id, "microsoft.msixsdk");
7488        assert_eq!(manifest.version, "1.7.32");
7489        assert_eq!(manifest.name, "MSIX SDK");
7490        assert!(!manifest.installers.is_empty());
7491    }
7492
7493    #[test]
7494    fn parses_installer_switches_from_yaml_manifest() {
7495        let yaml = r#"
7496PackageIdentifier: Test.Package
7497PackageVersion: 1.2.3
7498PackageName: Test Package
7499InstallerSwitches:
7500  SilentWithProgress: /SILENT
7501Installers:
7502  - Architecture: x64
7503    InstallerType: inno
7504    InstallerUrl: https://example.test/Test.Package.exe
7505    InstallerSha256: ABC123
7506    InstallerSwitches:
7507      Silent: /VERYSILENT
7508      Interactive: /HELP
7509"#;
7510
7511        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
7512        let installer = &manifest.installers[0];
7513
7514        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
7515        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
7516        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
7517    }
7518
7519    #[test]
7520    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
7521        let yaml = r#"
7522PackageIdentifier: Test.Package
7523PackageVersion: 1.2.3
7524PackageName: Test Package
7525Platform:
7526  - Windows.Desktop
7527MinimumOSVersion: 10.0.19041.0
7528Installers:
7529  - Architecture: x64
7530    InstallerType: exe
7531    InstallerUrl: https://example.test/Test.Package.exe
7532    InstallerSha256: ABC123
7533  - Architecture: x64
7534    InstallerType: msix
7535    Platform:
7536      - Windows.Universal
7537    MinimumOSVersion: 10.0.22621.0
7538    InstallerUrl: https://example.test/Test.Package.msix
7539    InstallerSha256: DEF456
7540"#;
7541
7542        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
7543
7544        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
7545        assert_eq!(
7546            manifest.installers[0].minimum_os_version.as_deref(),
7547            Some("10.0.19041.0")
7548        );
7549        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
7550        assert_eq!(
7551            manifest.installers[1].minimum_os_version.as_deref(),
7552            Some("10.0.22621.0")
7553        );
7554    }
7555
7556    #[test]
7557    fn installer_switch_arguments_prefer_manifest_switches() {
7558        let installer = Installer {
7559            architecture: None,
7560            installer_type: Some("inno".to_owned()),
7561            url: None,
7562            sha256: None,
7563            product_code: None,
7564            locale: None,
7565            scope: None,
7566            release_date: None,
7567            package_family_name: None,
7568            upgrade_code: None,
7569            platforms: Vec::new(),
7570            minimum_os_version: None,
7571            switches: InstallerSwitches {
7572                silent: Some("/mysilent".to_owned()),
7573                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
7574                interactive: Some("/myinteractive".to_owned()),
7575                ..InstallerSwitches::default()
7576            },
7577            commands: Vec::new(),
7578            package_dependencies: Vec::new(),
7579        };
7580        let manifest = Manifest {
7581            id: "Test.Package".to_owned(),
7582            name: "Test Package".to_owned(),
7583            version: "1.0.0".to_owned(),
7584            channel: String::new(),
7585            publisher: None,
7586            description: None,
7587            moniker: None,
7588            package_url: None,
7589            publisher_url: None,
7590            publisher_support_url: None,
7591            license: None,
7592            license_url: None,
7593            privacy_url: None,
7594            author: None,
7595            copyright: None,
7596            copyright_url: None,
7597            release_notes: None,
7598            release_notes_url: None,
7599            tags: Vec::new(),
7600            agreements: Vec::new(),
7601            package_dependencies: Vec::new(),
7602            documentation: Vec::new(),
7603            installers: Vec::new(),
7604        };
7605
7606        let mut silent_request = InstallRequest::new(PackageQuery::default());
7607        silent_request.mode = InstallerMode::Silent;
7608        let mut progress_request = InstallRequest::new(PackageQuery::default());
7609        progress_request.mode = InstallerMode::SilentWithProgress;
7610        let mut interactive_request = InstallRequest::new(PackageQuery::default());
7611        interactive_request.mode = InstallerMode::Interactive;
7612        assert_eq!(
7613            installer_command_arguments(
7614                "inno",
7615                &silent_request,
7616                &manifest,
7617                Path::new("installer.exe"),
7618                &installer
7619            ),
7620            vec!["/mysilent".to_owned()]
7621        );
7622        assert_eq!(
7623            installer_command_arguments(
7624                "inno",
7625                &progress_request,
7626                &manifest,
7627                Path::new("installer.exe"),
7628                &installer
7629            ),
7630            vec!["/mysilentwithprogress".to_owned()]
7631        );
7632        assert_eq!(
7633            installer_command_arguments(
7634                "inno",
7635                &interactive_request,
7636                &manifest,
7637                Path::new("installer.exe"),
7638                &installer
7639            ),
7640            vec!["/myinteractive".to_owned()]
7641        );
7642    }
7643
7644    #[test]
7645    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
7646        let installer = Installer {
7647            architecture: None,
7648            installer_type: Some("inno".to_owned()),
7649            url: None,
7650            sha256: None,
7651            product_code: None,
7652            locale: None,
7653            scope: None,
7654            release_date: None,
7655            package_family_name: None,
7656            upgrade_code: None,
7657            platforms: Vec::new(),
7658            minimum_os_version: None,
7659            switches: InstallerSwitches::default(),
7660            commands: Vec::new(),
7661            package_dependencies: Vec::new(),
7662        };
7663        let manifest = Manifest {
7664            id: "Test.Package".to_owned(),
7665            name: "Test Package".to_owned(),
7666            version: "1.0.0".to_owned(),
7667            channel: String::new(),
7668            publisher: None,
7669            description: None,
7670            moniker: None,
7671            package_url: None,
7672            publisher_url: None,
7673            publisher_support_url: None,
7674            license: None,
7675            license_url: None,
7676            privacy_url: None,
7677            author: None,
7678            copyright: None,
7679            copyright_url: None,
7680            release_notes: None,
7681            release_notes_url: None,
7682            tags: Vec::new(),
7683            agreements: Vec::new(),
7684            package_dependencies: Vec::new(),
7685            documentation: Vec::new(),
7686            installers: Vec::new(),
7687        };
7688        let mut progress_request = InstallRequest::new(PackageQuery::default());
7689        progress_request.mode = InstallerMode::SilentWithProgress;
7690        let mut silent_request = InstallRequest::new(PackageQuery::default());
7691        silent_request.mode = InstallerMode::Silent;
7692
7693        assert_eq!(
7694            installer_command_arguments(
7695                "inno",
7696                &progress_request,
7697                &manifest,
7698                Path::new("installer.exe"),
7699                &installer
7700            ),
7701            vec![
7702                "/SP-".to_owned(),
7703                "/SILENT".to_owned(),
7704                "/SUPPRESSMSGBOXES".to_owned(),
7705                "/NORESTART".to_owned()
7706            ]
7707        );
7708        assert_eq!(
7709            installer_command_arguments(
7710                "inno",
7711                &silent_request,
7712                &manifest,
7713                Path::new("installer.exe"),
7714                &installer
7715            ),
7716            vec![
7717                "/SP-".to_owned(),
7718                "/VERYSILENT".to_owned(),
7719                "/SUPPRESSMSGBOXES".to_owned(),
7720                "/NORESTART".to_owned()
7721            ]
7722        );
7723    }
7724
7725    #[test]
7726    fn installer_command_arguments_append_manifest_and_cli_switches() {
7727        let installer = Installer {
7728            architecture: None,
7729            installer_type: Some("msi".to_owned()),
7730            url: None,
7731            sha256: None,
7732            product_code: None,
7733            locale: None,
7734            scope: None,
7735            release_date: None,
7736            package_family_name: None,
7737            upgrade_code: None,
7738            platforms: Vec::new(),
7739            minimum_os_version: None,
7740            switches: InstallerSwitches {
7741                custom: Some("ADDLOCAL=Core".to_owned()),
7742                log: Some("/log \"<LOGPATH>\"".to_owned()),
7743                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
7744                ..InstallerSwitches::default()
7745            },
7746            commands: Vec::new(),
7747            package_dependencies: Vec::new(),
7748        };
7749        let manifest = Manifest {
7750            id: "ShareX.ShareX".to_owned(),
7751            name: "ShareX".to_owned(),
7752            version: "19.0.2".to_owned(),
7753            channel: String::new(),
7754            publisher: None,
7755            description: None,
7756            moniker: None,
7757            package_url: None,
7758            publisher_url: None,
7759            publisher_support_url: None,
7760            license: None,
7761            license_url: None,
7762            privacy_url: None,
7763            author: None,
7764            copyright: None,
7765            copyright_url: None,
7766            release_notes: None,
7767            release_notes_url: None,
7768            tags: Vec::new(),
7769            agreements: Vec::new(),
7770            package_dependencies: Vec::new(),
7771            documentation: Vec::new(),
7772            installers: Vec::new(),
7773        };
7774        let mut request = InstallRequest::new(PackageQuery::default());
7775        request.mode = InstallerMode::Silent;
7776        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
7777        request.custom = Some("REBOOT=ReallySuppress".to_owned());
7778        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
7779
7780        assert_eq!(
7781            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
7782            vec![
7783                "/i".to_owned(),
7784                r"C:\temp\ShareX.msi".to_owned(),
7785                "/quiet".to_owned(),
7786                "/norestart".to_owned(),
7787                "/log".to_owned(),
7788                r"C:\temp\winget.log".to_owned(),
7789                "ADDLOCAL=Core".to_owned(),
7790                "REBOOT=ReallySuppress".to_owned(),
7791                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
7792            ]
7793        );
7794    }
7795
7796    #[test]
7797    fn decompresses_mszyml_payload() {
7798        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
7799        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
7800        encoder.write_all(payload.as_bytes()).expect("write payload");
7801        let compressed = encoder.finish().expect("finish payload");
7802        let mut mszip = b"CK".to_vec();
7803        mszip.extend_from_slice(&compressed);
7804
7805        let decompressed = decompress_mszyml(&mszip).expect("decompress");
7806        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
7807        assert_eq!(parsed.versions[0].version, "1.2.3");
7808    }
7809
7810    #[test]
7811    fn decompresses_mszyml_payload_with_prefix() {
7812        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
7813        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
7814        encoder.write_all(payload.as_bytes()).expect("write payload");
7815        let compressed = encoder.finish().expect("finish payload");
7816        let mut prefixed = vec![0u8; 28];
7817        prefixed.extend_from_slice(b"CK");
7818        prefixed.extend_from_slice(&compressed);
7819
7820        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
7821        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
7822        assert_eq!(parsed.versions[0].version, "1.2.3");
7823    }
7824
7825    #[test]
7826    fn compares_versions_naturally() {
7827        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
7828        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
7829        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
7830    }
7831
7832    #[test]
7833    fn installer_hash_verification_fails_on_mismatch_without_override() {
7834        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
7835        assert!(result.is_err());
7836        assert!(
7837            result
7838                .expect_err("hash mismatch")
7839                .to_string()
7840                .contains("Installer hash mismatch")
7841        );
7842    }
7843
7844    #[test]
7845    fn installer_hash_verification_allows_mismatch_with_override() {
7846        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
7847        verify_installer_hash(None, b"pinget", false).expect("missing hash");
7848    }
7849
7850    #[test]
7851    fn search_query_many_includes_tag_and_command_conditions() {
7852        let query = PackageQuery {
7853            query: Some("terminal".to_owned()),
7854            ..PackageQuery::default()
7855        };
7856
7857        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
7858
7859        assert!(where_clause.contains("tags2"));
7860        assert!(where_clause.contains("commands2"));
7861        assert_eq!(params.len(), 5);
7862    }
7863
7864    #[test]
7865    fn show_query_single_omits_tag_and_command_conditions() {
7866        let query = PackageQuery {
7867            query: Some("terminal".to_owned()),
7868            ..PackageQuery::default()
7869        };
7870
7871        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
7872
7873        assert!(!where_clause.contains("tags2"));
7874        assert!(!where_clause.contains("commands2"));
7875        assert_eq!(params.len(), 3);
7876    }
7877
7878    #[test]
7879    fn explicit_tag_filter_uses_exact_match() {
7880        let query = PackageQuery {
7881            tag: Some("terminal".to_owned()),
7882            ..PackageQuery::default()
7883        };
7884
7885        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
7886
7887        assert!(where_clause.contains("tags2"));
7888        assert_eq!(params, vec!["terminal".to_owned()]);
7889    }
7890
7891    #[test]
7892    fn rest_tag_filter_uses_exact_match() {
7893        let query = PackageQuery {
7894            tag: Some("terminal".to_owned()),
7895            ..PackageQuery::default()
7896        };
7897        let info = RestInformation {
7898            required_package_match_fields: Vec::new(),
7899            unsupported_package_match_fields: Vec::new(),
7900            required_query_parameters: Vec::new(),
7901            ..RestInformation::default()
7902        };
7903
7904        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
7905        let filters = body["Filters"].as_array().expect("filters");
7906
7907        assert_eq!(filters.len(), 1);
7908        assert_eq!(filters[0]["PackageMatchField"], "Tag");
7909        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
7910        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
7911    }
7912
7913    #[test]
7914    fn selects_installer_using_requested_filters() {
7915        let installers = vec![
7916            Installer {
7917                architecture: Some("x86".to_owned()),
7918                installer_type: Some("zip".to_owned()),
7919                url: None,
7920                sha256: None,
7921                product_code: None,
7922                locale: Some("en-US".to_owned()),
7923                scope: Some("user".to_owned()),
7924                release_date: None,
7925                package_family_name: None,
7926                upgrade_code: None,
7927                platforms: Vec::new(),
7928                minimum_os_version: None,
7929                switches: InstallerSwitches::default(),
7930                commands: Vec::new(),
7931                package_dependencies: Vec::new(),
7932            },
7933            Installer {
7934                architecture: Some("x64".to_owned()),
7935                installer_type: Some("msix".to_owned()),
7936                url: None,
7937                sha256: None,
7938                product_code: None,
7939                locale: Some("en-US".to_owned()),
7940                scope: Some("user".to_owned()),
7941                release_date: None,
7942                package_family_name: None,
7943                upgrade_code: None,
7944                platforms: Vec::new(),
7945                minimum_os_version: None,
7946                switches: InstallerSwitches::default(),
7947                commands: vec!["demo".to_owned()],
7948                package_dependencies: Vec::new(),
7949            },
7950        ];
7951        let query = PackageQuery {
7952            installer_type: Some("msix".to_owned()),
7953            installer_architecture: Some("x64".to_owned()),
7954            install_scope: Some("user".to_owned()),
7955            locale: Some("en-US".to_owned()),
7956            ..PackageQuery::default()
7957        };
7958
7959        let selected = select_installer(&installers, &query).expect("selected installer");
7960        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
7961        assert_eq!(selected.architecture.as_deref(), Some("x64"));
7962    }
7963
7964    #[test]
7965    fn selects_first_installer_when_rank_ties() {
7966        let installers = vec![
7967            Installer {
7968                architecture: Some("x64".to_owned()),
7969                installer_type: Some("exe".to_owned()),
7970                url: None,
7971                sha256: None,
7972                product_code: None,
7973                locale: Some("en-US".to_owned()),
7974                scope: Some("machine".to_owned()),
7975                release_date: None,
7976                package_family_name: None,
7977                upgrade_code: None,
7978                platforms: Vec::new(),
7979                minimum_os_version: None,
7980                switches: InstallerSwitches::default(),
7981                commands: Vec::new(),
7982                package_dependencies: Vec::new(),
7983            },
7984            Installer {
7985                architecture: Some("x64".to_owned()),
7986                installer_type: Some("exe".to_owned()),
7987                url: None,
7988                sha256: None,
7989                product_code: None,
7990                locale: Some("en-US".to_owned()),
7991                scope: Some("user".to_owned()),
7992                release_date: None,
7993                package_family_name: None,
7994                upgrade_code: None,
7995                platforms: Vec::new(),
7996                minimum_os_version: None,
7997                switches: InstallerSwitches::default(),
7998                commands: Vec::new(),
7999                package_dependencies: Vec::new(),
8000            },
8001        ];
8002
8003        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
8004        assert_eq!(selected.scope.as_deref(), Some("machine"));
8005    }
8006
8007    #[test]
8008    fn selects_installer_using_requested_platform() {
8009        let installers = vec![
8010            Installer {
8011                architecture: Some("x64".to_owned()),
8012                installer_type: Some("exe".to_owned()),
8013                url: None,
8014                sha256: None,
8015                product_code: None,
8016                locale: Some("en-US".to_owned()),
8017                scope: Some("user".to_owned()),
8018                release_date: None,
8019                package_family_name: None,
8020                upgrade_code: None,
8021                platforms: vec!["Windows.Universal".to_owned()],
8022                minimum_os_version: None,
8023                switches: InstallerSwitches::default(),
8024                commands: Vec::new(),
8025                package_dependencies: Vec::new(),
8026            },
8027            Installer {
8028                architecture: Some("x64".to_owned()),
8029                installer_type: Some("exe".to_owned()),
8030                url: None,
8031                sha256: None,
8032                product_code: None,
8033                locale: Some("en-US".to_owned()),
8034                scope: Some("user".to_owned()),
8035                release_date: None,
8036                package_family_name: None,
8037                upgrade_code: None,
8038                platforms: vec!["Windows.Desktop".to_owned()],
8039                minimum_os_version: None,
8040                switches: InstallerSwitches::default(),
8041                commands: Vec::new(),
8042                package_dependencies: Vec::new(),
8043            },
8044        ];
8045        let query = PackageQuery {
8046            installer_type: Some("exe".to_owned()),
8047            platform: Some("Windows.Desktop".to_owned()),
8048            ..PackageQuery::default()
8049        };
8050
8051        let selected = select_installer(&installers, &query).expect("selected installer");
8052        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
8053    }
8054
8055    #[test]
8056    fn selects_installer_using_requested_os_version() {
8057        let installers = vec![
8058            Installer {
8059                architecture: Some("x64".to_owned()),
8060                installer_type: Some("exe".to_owned()),
8061                url: None,
8062                sha256: None,
8063                product_code: None,
8064                locale: Some("en-US".to_owned()),
8065                scope: Some("user".to_owned()),
8066                release_date: None,
8067                package_family_name: None,
8068                upgrade_code: None,
8069                platforms: Vec::new(),
8070                minimum_os_version: Some("10.0.22621.0".to_owned()),
8071                switches: InstallerSwitches::default(),
8072                commands: Vec::new(),
8073                package_dependencies: Vec::new(),
8074            },
8075            Installer {
8076                architecture: Some("x64".to_owned()),
8077                installer_type: Some("exe".to_owned()),
8078                url: None,
8079                sha256: None,
8080                product_code: None,
8081                locale: Some("en-US".to_owned()),
8082                scope: Some("user".to_owned()),
8083                release_date: None,
8084                package_family_name: None,
8085                upgrade_code: None,
8086                platforms: Vec::new(),
8087                minimum_os_version: Some("10.0.19041.0".to_owned()),
8088                switches: InstallerSwitches::default(),
8089                commands: Vec::new(),
8090                package_dependencies: Vec::new(),
8091            },
8092        ];
8093        let query = PackageQuery {
8094            installer_type: Some("exe".to_owned()),
8095            os_version: Some("10.0.19045.0".to_owned()),
8096            ..PackageQuery::default()
8097        };
8098
8099        let selected = select_installer(&installers, &query).expect("selected installer");
8100        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
8101    }
8102
8103    #[test]
8104    fn derives_correlation_name_candidates() {
8105        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
8106        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
8107        assert!(candidates.contains(&"PowerToys".to_owned()));
8108    }
8109
8110    #[test]
8111    fn correlates_installed_package_to_available_match() {
8112        let installed = InstalledPackage {
8113            name: "PowerToys (Preview) x64".to_owned(),
8114            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
8115            installed_version: "0.98.1".to_owned(),
8116            publisher: None,
8117            scope: Some("Machine".to_owned()),
8118            installer_category: Some("exe".to_owned()),
8119            install_location: None,
8120            package_family_names: Vec::new(),
8121            product_codes: Vec::new(),
8122            upgrade_codes: Vec::new(),
8123            correlated: None,
8124        };
8125        let candidates = vec![SearchMatch {
8126            source_name: "winget".to_owned(),
8127            source_kind: SourceKind::PreIndexed,
8128            id: "Microsoft.PowerToys".to_owned(),
8129            name: "PowerToys".to_owned(),
8130            moniker: None,
8131            version: Some("0.98.1".to_owned()),
8132            channel: None,
8133            match_criteria: None,
8134        }];
8135
8136        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
8137        assert_eq!(correlated.id, "Microsoft.PowerToys");
8138    }
8139
8140    #[test]
8141    fn list_query_uses_available_lookup_for_tag_filters() {
8142        let query = ListQuery {
8143            tag: Some("terminal".to_owned()),
8144            ..ListQuery::default()
8145        };
8146
8147        assert!(list_query_needs_available_lookup(&query));
8148    }
8149
8150    #[test]
8151    fn list_tag_filter_requires_correlated_match() {
8152        let package = InstalledPackage {
8153            name: "PowerToys (Preview) x64".to_owned(),
8154            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
8155            installed_version: "0.98.1".to_owned(),
8156            publisher: None,
8157            scope: Some("Machine".to_owned()),
8158            installer_category: Some("exe".to_owned()),
8159            install_location: None,
8160            package_family_names: Vec::new(),
8161            product_codes: Vec::new(),
8162            upgrade_codes: Vec::new(),
8163            correlated: None,
8164        };
8165        let query = ListQuery {
8166            tag: Some("powertoys".to_owned()),
8167            ..ListQuery::default()
8168        };
8169
8170        assert!(!list_package_matches(&package, &query));
8171    }
8172
8173    #[test]
8174    fn list_lookup_ignores_display_count() {
8175        let query = ListQuery {
8176            query: Some("git".to_owned()),
8177            count: Some(5),
8178            ..ListQuery::default()
8179        };
8180
8181        let package_query = package_query_from_list_query(&query);
8182        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
8183    }
8184
8185    #[test]
8186    fn strict_list_correlation_avoids_short_substring_matches() {
8187        let installed = InstalledPackage {
8188            name: "GitHub".to_owned(),
8189            local_id: r"ARP\User\X64\GitHub".to_owned(),
8190            installed_version: "1.0.0".to_owned(),
8191            publisher: None,
8192            scope: Some("User".to_owned()),
8193            installer_category: Some("exe".to_owned()),
8194            install_location: None,
8195            package_family_names: Vec::new(),
8196            product_codes: Vec::new(),
8197            upgrade_codes: Vec::new(),
8198            correlated: None,
8199        };
8200        let candidates = vec![SearchMatch {
8201            source_name: "winget".to_owned(),
8202            source_kind: SourceKind::PreIndexed,
8203            id: "Git.Git.PreRelease".to_owned(),
8204            name: "Git".to_owned(),
8205            moniker: None,
8206            version: Some("2.54.0".to_owned()),
8207            channel: None,
8208            match_criteria: None,
8209        }];
8210
8211        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
8212    }
8213
8214    #[test]
8215    fn detects_available_upgrade_from_correlated_package() {
8216        let package = InstalledPackage {
8217            name: "AzCopy v10".to_owned(),
8218            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
8219            installed_version: "10.32.2".to_owned(),
8220            publisher: None,
8221            scope: Some("Machine".to_owned()),
8222            installer_category: Some("exe".to_owned()),
8223            install_location: None,
8224            package_family_names: Vec::new(),
8225            product_codes: Vec::new(),
8226            upgrade_codes: Vec::new(),
8227            correlated: Some(SearchMatch {
8228                source_name: "winget".to_owned(),
8229                source_kind: SourceKind::PreIndexed,
8230                id: "Microsoft.Azure.AZCopy.10".to_owned(),
8231                name: "AzCopy v10".to_owned(),
8232                moniker: None,
8233                version: Some("10.32.3".to_owned()),
8234                channel: None,
8235                match_criteria: None,
8236            }),
8237        };
8238
8239        assert!(installed_package_has_upgrade(&package));
8240    }
8241
8242    #[test]
8243    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
8244        let package = InstalledPackage {
8245            name: "Example Tool".to_owned(),
8246            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
8247            installed_version: "Unknown".to_owned(),
8248            publisher: None,
8249            scope: Some("User".to_owned()),
8250            installer_category: Some("exe".to_owned()),
8251            install_location: None,
8252            package_family_names: Vec::new(),
8253            product_codes: Vec::new(),
8254            upgrade_codes: Vec::new(),
8255            correlated: Some(SearchMatch {
8256                source_name: "winget".to_owned(),
8257                source_kind: SourceKind::PreIndexed,
8258                id: "Contoso.ExampleTool".to_owned(),
8259                name: "Example Tool".to_owned(),
8260                moniker: None,
8261                version: Some("2.0.0".to_owned()),
8262                channel: None,
8263                match_criteria: None,
8264            }),
8265        };
8266        let query = ListQuery {
8267            upgrade_only: true,
8268            include_unknown: true,
8269            ..Default::default()
8270        };
8271
8272        assert!(installed_package_matches_upgrade_filter(&package, &query));
8273        assert_eq!(
8274            list_match_from_installed(package).available_version.as_deref(),
8275            Some("2.0.0")
8276        );
8277    }
8278
8279    #[test]
8280    fn create_repair_list_query_includes_installed_selectors() {
8281        let request = RepairRequest {
8282            query: PackageQuery {
8283                query: Some("powertoys".to_owned()),
8284                id: Some("Microsoft.PowerToys".to_owned()),
8285                name: Some("PowerToys".to_owned()),
8286                moniker: Some("powertoys".to_owned()),
8287                source: Some("winget".to_owned()),
8288                exact: true,
8289                version: Some("0.98.1".to_owned()),
8290                install_scope: Some("Machine".to_owned()),
8291                ..PackageQuery::default()
8292            },
8293            manifest_path: None,
8294            product_code: Some("{GUID}".to_owned()),
8295            mode: InstallerMode::Silent,
8296            log_path: Some(PathBuf::from("repair.log")),
8297            accept_package_agreements: true,
8298            force: true,
8299            ignore_security_hash: true,
8300        };
8301
8302        let query = Repository::create_repair_list_query(&request);
8303        assert_eq!(query.query.as_deref(), Some("powertoys"));
8304        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
8305        assert_eq!(query.name.as_deref(), Some("PowerToys"));
8306        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
8307        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
8308        assert_eq!(query.version.as_deref(), Some("0.98.1"));
8309        assert_eq!(query.source.as_deref(), Some("winget"));
8310        assert!(query.exact);
8311        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
8312        assert_eq!(query.count, Some(100));
8313    }
8314
8315    #[test]
8316    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
8317        let request = RepairRequest {
8318            query: PackageQuery {
8319                name: Some("PowerToys".to_owned()),
8320                source: Some("winget".to_owned()),
8321                locale: Some("en-US".to_owned()),
8322                installer_architecture: Some("x64".to_owned()),
8323                install_scope: Some("Machine".to_owned()),
8324                ..PackageQuery::default()
8325            },
8326            manifest_path: None,
8327            product_code: None,
8328            mode: InstallerMode::SilentWithProgress,
8329            log_path: Some(PathBuf::from("repair.log")),
8330            accept_package_agreements: true,
8331            force: false,
8332            ignore_security_hash: true,
8333        };
8334        let installed = ListMatch {
8335            name: "PowerToys".to_owned(),
8336            id: "Microsoft.PowerToys".to_owned(),
8337            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
8338            installed_version: "0.98.1".to_owned(),
8339            available_version: Some("0.98.2".to_owned()),
8340            source_name: Some("winget".to_owned()),
8341            publisher: None,
8342            scope: Some("Machine".to_owned()),
8343            installer_category: Some("exe".to_owned()),
8344            install_location: None,
8345            package_family_names: Vec::new(),
8346            product_codes: Vec::new(),
8347            upgrade_codes: Vec::new(),
8348        };
8349
8350        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
8351        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
8352        assert!(install_request.query.query.is_none());
8353        assert!(install_request.query.name.is_none());
8354        assert!(install_request.query.moniker.is_none());
8355        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
8356        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
8357        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
8358        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
8359        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
8360        assert!(install_request.query.exact);
8361        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
8362        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
8363        assert!(install_request.accept_package_agreements);
8364        assert!(install_request.force);
8365        assert!(install_request.ignore_security_hash);
8366    }
8367
8368    #[test]
8369    fn source_scoped_pin_operations_round_trip() {
8370        let app_root = temp_app_root("pins");
8371        let result = (|| -> Result<()> {
8372            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8373            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
8374            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
8375            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
8376
8377            assert_eq!(repository.list_pins(None)?.len(), 3);
8378            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
8379            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
8380
8381            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
8382            assert_eq!(repository.list_pins(None)?.len(), 2);
8383            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
8384
8385            repository.reset_pins(Some("test"))?;
8386            let remaining = repository.list_pins(None)?;
8387            assert_eq!(remaining.len(), 1);
8388            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
8389
8390            repository.reset_pins(None)?;
8391            assert!(repository.list_pins(None)?.is_empty());
8392            Ok(())
8393        })();
8394
8395        let _ = fs::remove_dir_all(&app_root);
8396        result.expect("pin round trip");
8397    }
8398
8399    #[test]
8400    fn pin_operations_work_with_packaged_pin_schema() {
8401        let app_root = temp_app_root("pins_packaged_schema");
8402        let result = (|| -> Result<()> {
8403            let db_path = pins_db_path(&app_root);
8404            if let Some(parent) = db_path.parent() {
8405                fs::create_dir_all(parent)?;
8406            }
8407
8408            let conn = Connection::open(&db_path)?;
8409            conn.execute_batch(
8410                "CREATE TABLE pin (
8411                    package_id TEXT NOT NULL,
8412                    source_id TEXT NOT NULL,
8413                    type INTEGER NOT NULL,
8414                    version TEXT NOT NULL,
8415                    PRIMARY KEY (package_id, source_id)
8416                )",
8417            )?;
8418            drop(conn);
8419
8420            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8421            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
8422
8423            let pins = repository.list_pins(None)?;
8424            assert_eq!(pins.len(), 1);
8425            assert_eq!(pins[0].package_id, "Contoso.Tool");
8426            assert_eq!(pins[0].version, "1.2.*");
8427            assert_eq!(pins[0].source_id, "winget");
8428            assert_eq!(pins[0].pin_type, PinType::Gating);
8429
8430            let conn = Connection::open(&db_path)?;
8431            let stored_pin = conn.query_row(
8432                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
8433                rusqlite::params!["Contoso.Tool", "winget"],
8434                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
8435            )?;
8436            assert_eq!(stored_pin.0, 3);
8437            assert_eq!(stored_pin.1, "1.2.*");
8438
8439            Ok(())
8440        })();
8441
8442        let _ = fs::remove_dir_all(&app_root);
8443        result.expect("packaged pin schema round trip");
8444    }
8445
8446    #[test]
8447    fn version_pin_patterns_match_exact_and_prefix_values() {
8448        assert!(version_matches_pin_pattern("1.2.3", "*"));
8449        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
8450        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
8451        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
8452        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
8453    }
8454
8455    #[test]
8456    fn source_specific_pins_override_source_agnostic_pins() {
8457        let item = ListMatch {
8458            name: "Contoso Tool".to_owned(),
8459            id: "Contoso.Tool".to_owned(),
8460            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
8461            installed_version: "1.0.0".to_owned(),
8462            available_version: Some("2.0.0".to_owned()),
8463            source_name: Some("winget".to_owned()),
8464            publisher: None,
8465            scope: None,
8466            installer_category: None,
8467            install_location: None,
8468            package_family_names: Vec::new(),
8469            product_codes: Vec::new(),
8470            upgrade_codes: Vec::new(),
8471        };
8472        let pins = vec![
8473            PinRecord {
8474                package_id: "Contoso.Tool".to_owned(),
8475                version: "*".to_owned(),
8476                source_id: String::new(),
8477                pin_type: PinType::Blocking,
8478            },
8479            PinRecord {
8480                package_id: "Contoso.Tool".to_owned(),
8481                version: "2.0.*".to_owned(),
8482                source_id: "winget".to_owned(),
8483                pin_type: PinType::Pinning,
8484            },
8485        ];
8486
8487        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
8488    }
8489
8490    #[test]
8491    fn blocking_pin_blocks_upgrade() {
8492        let item = ListMatch {
8493            name: "Contoso Tool".to_owned(),
8494            id: "Contoso.Tool".to_owned(),
8495            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
8496            installed_version: "1.0.0".to_owned(),
8497            available_version: Some("2.0.0".to_owned()),
8498            source_name: Some("winget".to_owned()),
8499            publisher: None,
8500            scope: None,
8501            installer_category: None,
8502            install_location: None,
8503            package_family_names: Vec::new(),
8504            product_codes: Vec::new(),
8505            upgrade_codes: Vec::new(),
8506        };
8507        let pins = vec![PinRecord {
8508            package_id: "Contoso.Tool".to_owned(),
8509            version: "*".to_owned(),
8510            source_id: "winget".to_owned(),
8511            pin_type: PinType::Blocking,
8512        }];
8513
8514        assert!(is_upgrade_blocked_by_pin(&item, &pins));
8515    }
8516
8517    #[cfg(windows)]
8518    #[test]
8519    fn parses_msix_package_full_name_into_version_and_family() {
8520        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
8521            .expect("package metadata");
8522
8523        assert_eq!(parsed.version, "0.98.1.0");
8524        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
8525    }
8526
8527    #[cfg(windows)]
8528    #[test]
8529    fn recognizes_windows_system_paths() {
8530        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
8531        assert!(!is_windows_system_path(
8532            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
8533        ));
8534    }
8535
8536    #[test]
8537    fn search_ranking_prefers_exact_name_over_tag_match() {
8538        let query = PackageQuery {
8539            query: Some("PowerToys".to_owned()),
8540            ..Default::default()
8541        };
8542        let exact_name = SearchMatch {
8543            source_name: "winget".to_owned(),
8544            source_kind: SourceKind::PreIndexed,
8545            id: "Microsoft.PowerToys".to_owned(),
8546            name: "PowerToys".to_owned(),
8547            moniker: Some("powertoys".to_owned()),
8548            version: Some("0.98.1".to_owned()),
8549            channel: None,
8550            match_criteria: Some("Moniker: powertoys".to_owned()),
8551        };
8552        let tag_match = SearchMatch {
8553            source_name: "winget".to_owned(),
8554            source_kind: SourceKind::PreIndexed,
8555            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
8556            name: "QR Codes for Command Palette".to_owned(),
8557            moniker: None,
8558            version: Some("0.4.0.0".to_owned()),
8559            channel: None,
8560            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
8561        };
8562
8563        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
8564    }
8565
8566    #[test]
8567    fn select_best_text_match_prefers_exact_tag_value() {
8568        let values = vec![
8569            "microsoft-powertoys".to_owned(),
8570            "powertoys-run".to_owned(),
8571            "powertoys".to_owned(),
8572        ];
8573
8574        assert_eq!(
8575            select_best_text_match(values, "PowerToys", false).as_deref(),
8576            Some("powertoys")
8577        );
8578    }
8579
8580    #[test]
8581    fn search_match_criteria_is_blank_for_name_match() {
8582        let query = PackageQuery {
8583            query: Some("PowerToys".to_owned()),
8584            ..Default::default()
8585        };
8586
8587        let result = infer_match_criteria(
8588            "Microsoft.PowerToys",
8589            "PowerToys",
8590            Some("powertoys"),
8591            &query,
8592            SearchSemantics::Many,
8593            |_| Ok(Some("powertoys".to_owned())),
8594            |_| Ok(None),
8595        )
8596        .expect("match criteria");
8597
8598        assert_eq!(result, None);
8599    }
8600
8601    #[test]
8602    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
8603        let query = PackageQuery {
8604            query: Some("PowerToys".to_owned()),
8605            ..Default::default()
8606        };
8607        let unknown_version = SearchMatch {
8608            source_name: "msstore".to_owned(),
8609            source_kind: SourceKind::Rest,
8610            id: "XP89DCGQ3K6VLD".to_owned(),
8611            name: "Microsoft PowerToys".to_owned(),
8612            moniker: None,
8613            version: Some("Unknown".to_owned()),
8614            channel: None,
8615            match_criteria: None,
8616        };
8617        let tag_match = SearchMatch {
8618            source_name: "winget".to_owned(),
8619            source_kind: SourceKind::PreIndexed,
8620            id: "Riri.QRCodeforCmdPal".to_owned(),
8621            name: "QR Code for CmdPal".to_owned(),
8622            moniker: None,
8623            version: Some("0.0.1.0".to_owned()),
8624            channel: None,
8625            match_criteria: Some("Tag: powertoys".to_owned()),
8626        };
8627
8628        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
8629    }
8630
8631    #[test]
8632    fn search_source_fetch_results_ignore_small_display_count() {
8633        let query = PackageQuery {
8634            count: Some(5),
8635            ..Default::default()
8636        };
8637
8638        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
8639        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
8640    }
8641
8642    #[test]
8643    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
8644        let query = PackageQuery {
8645            query: Some("PowerToys".to_owned()),
8646            ..Default::default()
8647        };
8648        let prefix_name = SearchMatch {
8649            source_name: "winget".to_owned(),
8650            source_kind: SourceKind::PreIndexed,
8651            id: "advaith.CurrencyConverterPowerToys".to_owned(),
8652            name: "PowerToys-Run-Currency-Converter".to_owned(),
8653            moniker: None,
8654            version: Some("1.5.4".to_owned()),
8655            channel: None,
8656            match_criteria: None,
8657        };
8658        let exact_tag = SearchMatch {
8659            source_name: "winget".to_owned(),
8660            source_kind: SourceKind::PreIndexed,
8661            id: "Riri.QRCodeforCmdPal".to_owned(),
8662            name: "QR Code for CmdPal".to_owned(),
8663            moniker: None,
8664            version: Some("0.0.1.0".to_owned()),
8665            channel: None,
8666            match_criteria: Some("Tag: powertoys".to_owned()),
8667        };
8668
8669        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
8670    }
8671
8672    #[test]
8673    fn list_sort_prefers_main_package_then_sparse_app() {
8674        let main = InstalledPackage {
8675            name: "PowerToys (Preview) x64".to_owned(),
8676            local_id: r"ARP\User\X64\PowerToys".to_owned(),
8677            installed_version: "0.98.1".to_owned(),
8678            publisher: None,
8679            scope: Some("User".to_owned()),
8680            installer_category: Some("exe".to_owned()),
8681            install_location: None,
8682            package_family_names: Vec::new(),
8683            product_codes: Vec::new(),
8684            upgrade_codes: Vec::new(),
8685            correlated: None,
8686        };
8687        let sparse = InstalledPackage {
8688            name: "PowerToys.SparseApp".to_owned(),
8689            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
8690            installed_version: "0.98.1.0".to_owned(),
8691            publisher: None,
8692            scope: Some("User".to_owned()),
8693            installer_category: Some("msix".to_owned()),
8694            install_location: None,
8695            package_family_names: Vec::new(),
8696            product_codes: Vec::new(),
8697            upgrade_codes: Vec::new(),
8698            correlated: None,
8699        };
8700        let extension = InstalledPackage {
8701            name: "PowerToys FileLocksmith Context Menu".to_owned(),
8702            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
8703            installed_version: "0.98.1.0".to_owned(),
8704            publisher: None,
8705            scope: Some("User".to_owned()),
8706            installer_category: Some("msix".to_owned()),
8707            install_location: None,
8708            package_family_names: Vec::new(),
8709            product_codes: Vec::new(),
8710            upgrade_codes: Vec::new(),
8711            correlated: None,
8712        };
8713
8714        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
8715        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
8716    }
8717
8718    #[test]
8719    #[cfg(windows)]
8720    fn arp_subkey_name_extracts_registry_subkey() {
8721        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
8722        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
8723    }
8724
8725    #[test]
8726    #[cfg(windows)]
8727    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
8728        let installed = ListMatch {
8729            name: "ShareX".to_owned(),
8730            id: "ShareX.ShareX".to_owned(),
8731            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
8732            installed_version: "19.0.2".to_owned(),
8733            available_version: None,
8734            source_name: Some("winget".to_owned()),
8735            publisher: Some("ShareX Team".to_owned()),
8736            scope: Some("Machine".to_owned()),
8737            installer_category: Some("exe".to_owned()),
8738            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
8739            package_family_names: Vec::new(),
8740            product_codes: Vec::new(),
8741            upgrade_codes: Vec::new(),
8742        };
8743
8744        assert!(registry_entry_matches_installed_package(
8745            "ShareX", "ShareX", None, &installed
8746        ));
8747        assert!(!registry_entry_matches_installed_package(
8748            "ShareX.ShareX",
8749            "ShareX.ShareX",
8750            None,
8751            &installed
8752        ));
8753    }
8754
8755    #[test]
8756    #[cfg(windows)]
8757    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
8758        assert_eq!(
8759            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
8760            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
8761        );
8762        assert_eq!(
8763            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
8764            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
8765        );
8766        assert_eq!(
8767            r#""C:\Program Files\ShareX\unins000.exe""#,
8768            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
8769        );
8770        assert_eq!(
8771            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
8772            build_uninstall_command(
8773                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
8774                true,
8775                false,
8776            )
8777        );
8778    }
8779
8780    #[test]
8781    #[cfg(windows)]
8782    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
8783        let installed = ListMatch {
8784            name: "lazygit".to_owned(),
8785            id: "JesseDuffield.lazygit".to_owned(),
8786            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
8787            installed_version: "0.61.1".to_owned(),
8788            available_version: None,
8789            source_name: Some("winget".to_owned()),
8790            publisher: Some("Jesse Duffield".to_owned()),
8791            scope: Some("User".to_owned()),
8792            installer_category: Some("exe".to_owned()),
8793            install_location: Some(
8794                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
8795            ),
8796            package_family_names: Vec::new(),
8797            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
8798            upgrade_codes: Vec::new(),
8799        };
8800        let mut request = UninstallRequest::new(PackageQuery {
8801            id: Some("JesseDuffield.lazygit".to_owned()),
8802            install_scope: Some("user".to_owned()),
8803            ..PackageQuery::default()
8804        });
8805        request.mode = InstallerMode::Silent;
8806
8807        assert_eq!(
8808            Some(vec![
8809                "uninstall".to_owned(),
8810                "--id".to_owned(),
8811                "JesseDuffield.lazygit".to_owned(),
8812                "--exact".to_owned(),
8813                "--disable-interactivity".to_owned(),
8814                "--source".to_owned(),
8815                "winget".to_owned(),
8816                "--scope".to_owned(),
8817                "user".to_owned(),
8818                "--silent".to_owned(),
8819            ]),
8820            build_winget_uninstall_arguments(&installed, &request)
8821        );
8822
8823        assert_eq!(
8824            Some(vec![
8825                "uninstall".to_owned(),
8826                "--id".to_owned(),
8827                "JesseDuffield.lazygit".to_owned(),
8828                "--exact".to_owned(),
8829                "--disable-interactivity".to_owned(),
8830                "--source".to_owned(),
8831                "winget".to_owned(),
8832                "--silent".to_owned(),
8833            ]),
8834            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
8835        );
8836    }
8837}