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    // Note: MSIX packages used to be hard-skipped here, but that prevented obvious
2646    // correlations like `Microsoft Teams` (MSIX) → `Microsoft.Teams` (catalog).
2647    // Name-based correlation now applies uniformly; MSIX entries whose installed
2648    // name is an unresolved resource string (e.g. `ms-resource:appDisplayName`)
2649    // simply fail to match and return None, same as before.
2650
2651    let installed_name = normalize_correlation_name(&package.name);
2652    let installed_name_lower = package.name.to_ascii_lowercase();
2653    let candidate_names = correlation_name_candidates(&package.name);
2654
2655    candidates
2656        .iter()
2657        .filter_map(|candidate| {
2658            let candidate_name = normalize_correlation_name(&candidate.name);
2659            let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
2660                1000
2661            } else if candidate_names.iter().any(|name| {
2662                let normalized = normalize_correlation_name(name);
2663                normalized == candidate_name
2664            }) {
2665                900
2666            } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
2667                700
2668            } else {
2669                0
2670            };
2671
2672            if base_score == 0 {
2673                return None;
2674            }
2675
2676            // Tiebreaker so we pick the *right* candidate when multiple catalog
2677            // entries collapse to the same normalized name or all loose-match a
2678            // long installed name. Two failure modes this addresses:
2679            //   • `Notepad++` and `Notepad--` both normalize to `notepad` (the
2680            //     alphanumeric filter strips `+`/`-`), so both score 900 and
2681            //     `max_by_key` arbitrarily picked the latter.
2682            //   • Loose substring match picks up `Studio` inside
2683            //     `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
2684            //     `Microsoft.DotNet.SDK.10` when both score 700.
2685            // The bonus rewards catalog names that appear verbatim in the
2686            // installed display name — strongly when they anchor the start,
2687            // weaker when they're embedded.
2688            let candidate_lower = candidate.name.to_ascii_lowercase();
2689            let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
2690                100
2691            } else if installed_name_lower.contains(&candidate_lower) {
2692                50
2693            } else {
2694                0
2695            };
2696
2697            Some((base_score + prefix_bonus, candidate.clone()))
2698        })
2699        .max_by_key(|(score, _)| *score)
2700        .map(|(_, candidate)| candidate)
2701}
2702
2703fn correlation_name_candidates(name: &str) -> Vec<String> {
2704    let mut candidates = Vec::new();
2705    candidates.push(name.trim().to_owned());
2706
2707    let mut trimmed = name.trim().to_owned();
2708    if let Some(index) = trimmed.find(" (") {
2709        trimmed.truncate(index);
2710    }
2711
2712    let mut words = Vec::new();
2713    for token in trimmed.split_whitespace() {
2714        let lower = token
2715            .trim_matches(|ch: char| ch == '(' || ch == ')')
2716            .to_ascii_lowercase();
2717        if !words.is_empty()
2718            && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
2719        {
2720            break;
2721        }
2722        words.push(token);
2723    }
2724
2725    if !words.is_empty() {
2726        candidates.push(words.join(" "));
2727    }
2728
2729    candidates.sort();
2730    candidates.dedup();
2731    candidates
2732}
2733
2734fn normalize_correlation_name(value: &str) -> String {
2735    value
2736        .chars()
2737        .filter(|ch| ch.is_ascii_alphanumeric())
2738        .flat_map(|ch| ch.to_lowercase())
2739        .collect()
2740}
2741
2742#[cfg(windows)]
2743fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
2744    let mut packages = Vec::new();
2745    let mut seen = BTreeSet::new();
2746
2747    let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
2748    let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
2749
2750    if machine {
2751        collect_uninstall_view(
2752            &mut packages,
2753            &mut seen,
2754            RegKey::predef(HKEY_LOCAL_MACHINE),
2755            "Machine",
2756            "X64",
2757            KEY_READ | KEY_WOW64_64KEY,
2758        )?;
2759        collect_uninstall_view(
2760            &mut packages,
2761            &mut seen,
2762            RegKey::predef(HKEY_LOCAL_MACHINE),
2763            "Machine",
2764            "X86",
2765            KEY_READ | KEY_WOW64_32KEY,
2766        )?;
2767        collect_appmodel_packages(
2768            &mut packages,
2769            &mut seen,
2770            RegKey::predef(HKEY_LOCAL_MACHINE),
2771            "Machine",
2772            KEY_READ | KEY_WOW64_64KEY,
2773        )?;
2774    }
2775
2776    if user {
2777        collect_uninstall_view(
2778            &mut packages,
2779            &mut seen,
2780            RegKey::predef(HKEY_CURRENT_USER),
2781            "User",
2782            "X64",
2783            KEY_READ,
2784        )?;
2785        collect_appmodel_packages(
2786            &mut packages,
2787            &mut seen,
2788            RegKey::predef(HKEY_CURRENT_USER),
2789            "User",
2790            KEY_READ,
2791        )?;
2792    }
2793
2794    Ok(packages)
2795}
2796
2797#[cfg(not(windows))]
2798fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
2799    Ok(Vec::new())
2800}
2801
2802#[cfg(windows)]
2803fn collect_uninstall_view(
2804    packages: &mut Vec<InstalledPackage>,
2805    seen: &mut BTreeSet<String>,
2806    root: RegKey,
2807    scope: &str,
2808    arch: &str,
2809    flags: u32,
2810) -> Result<()> {
2811    const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
2812
2813    let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
2814        Ok(key) => key,
2815        Err(_) => return Ok(()),
2816    };
2817
2818    for key_name in uninstall.enum_keys().flatten() {
2819        let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
2820            Ok(key) => key,
2821            Err(_) => continue,
2822        };
2823        if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
2824        {
2825            continue;
2826        }
2827
2828        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
2829            continue;
2830        };
2831
2832        let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
2833        let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
2834        let publisher = read_reg_string(&subkey, "Publisher");
2835        let install_location = read_reg_string(&subkey, "InstallLocation");
2836        let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
2837            .into_iter()
2838            .collect::<Vec<_>>();
2839        let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
2840        if product_codes.is_empty() && looks_like_product_code(&key_name) {
2841            product_codes.push(key_name.to_ascii_lowercase());
2842        }
2843        let upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
2844        let installer_category =
2845            if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
2846                Some("msi".to_owned())
2847            } else if key_name.starts_with("MSIX\\") {
2848                Some("msix".to_owned())
2849            } else {
2850                Some("exe".to_owned())
2851            };
2852
2853        let dedupe_key = format!(
2854            "{}|{}|{}|{}",
2855            local_id,
2856            name.to_ascii_lowercase(),
2857            installed_version.to_ascii_lowercase(),
2858            publisher.clone().unwrap_or_default().to_ascii_lowercase()
2859        );
2860        if !seen.insert(dedupe_key) {
2861            continue;
2862        }
2863
2864        packages.push(InstalledPackage {
2865            name,
2866            local_id,
2867            installed_version,
2868            publisher,
2869            scope: Some(scope.to_owned()),
2870            installer_category,
2871            install_location,
2872            package_family_names,
2873            product_codes,
2874            upgrade_codes,
2875            correlated: None,
2876        });
2877    }
2878
2879    Ok(())
2880}
2881
2882#[cfg(windows)]
2883fn collect_appmodel_packages(
2884    packages: &mut Vec<InstalledPackage>,
2885    seen: &mut BTreeSet<String>,
2886    root: RegKey,
2887    scope: &str,
2888    flags: u32,
2889) -> Result<()> {
2890    const APPMODEL_PACKAGES_PATH: &str =
2891        r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
2892
2893    let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
2894        Ok(key) => key,
2895        Err(_) => return Ok(()),
2896    };
2897
2898    for key_name in appmodel.enum_keys().flatten() {
2899        let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
2900            Ok(key) => key,
2901            Err(_) => continue,
2902        };
2903
2904        let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
2905            continue;
2906        };
2907        let install_location = read_reg_string(&subkey, "PackageRootFolder");
2908        if install_location.as_deref().is_some_and(is_windows_system_path) {
2909            continue;
2910        }
2911
2912        let Some(metadata) = parse_msix_package_full_name(&key_name) else {
2913            continue;
2914        };
2915
2916        let local_id = format!(r"MSIX\{key_name}");
2917        let dedupe_key = format!(
2918            "{}|{}|{}",
2919            local_id,
2920            name.to_ascii_lowercase(),
2921            metadata.version.to_ascii_lowercase()
2922        );
2923        if !seen.insert(dedupe_key) {
2924            continue;
2925        }
2926
2927        packages.push(InstalledPackage {
2928            name,
2929            local_id,
2930            installed_version: metadata.version,
2931            publisher: None,
2932            scope: Some(scope.to_owned()),
2933            installer_category: Some("msix".to_owned()),
2934            install_location,
2935            package_family_names: vec![metadata.family_name],
2936            product_codes: Vec::new(),
2937            upgrade_codes: Vec::new(),
2938            correlated: None,
2939        });
2940    }
2941
2942    Ok(())
2943}
2944
2945#[cfg(windows)]
2946struct ParsedMsixPackageFullName {
2947    version: String,
2948    family_name: String,
2949}
2950
2951#[cfg(windows)]
2952fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
2953    let segments = value.split('_').collect::<Vec<_>>();
2954    if segments.len() < 5 {
2955        return None;
2956    }
2957
2958    let name = segments[..segments.len() - 4].join("_");
2959    if name.is_empty() {
2960        return None;
2961    }
2962
2963    let version = segments[segments.len() - 4].trim();
2964    let resource_id = segments[segments.len() - 2].trim();
2965    let publisher_id = segments[segments.len() - 1].trim();
2966    if version.is_empty() || publisher_id.is_empty() {
2967        return None;
2968    }
2969
2970    let family_name = if resource_id.is_empty() {
2971        format!("{name}_{publisher_id}")
2972    } else {
2973        format!("{name}_{resource_id}_{publisher_id}")
2974    };
2975
2976    Some(ParsedMsixPackageFullName {
2977        version: version.to_owned(),
2978        family_name,
2979    })
2980}
2981
2982#[cfg(windows)]
2983fn is_windows_system_path(path: &str) -> bool {
2984    path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
2985}
2986
2987#[cfg(windows)]
2988fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
2989    key.get_value::<String, _>(value_name)
2990        .ok()
2991        .map(|value| value.trim().to_owned())
2992        .filter(|value| !value.is_empty())
2993}
2994
2995#[cfg(windows)]
2996fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
2997    key.get_value::<u32, _>(value_name).ok()
2998}
2999
3000#[cfg(windows)]
3001fn looks_like_product_code(value: &str) -> bool {
3002    value.starts_with('{') && value.ends_with('}')
3003}
3004
3005fn ensure_app_dirs(app_root: &Path) -> Result<()> {
3006    fs::create_dir_all(app_root).context("failed to create app root directory")?;
3007    if uses_packaged_layout(app_root) {
3008        fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
3009        let user_sources_path = packaged_user_sources_path(app_root)?;
3010        if let Some(parent) = user_sources_path.parent() {
3011            fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
3012        }
3013    } else {
3014        fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
3015    }
3016    Ok(())
3017}
3018
3019fn default_app_root() -> Result<PathBuf> {
3020    if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
3021        return Ok(PathBuf::from(app_root));
3022    }
3023
3024    let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
3025
3026    #[cfg(windows)]
3027    {
3028        if has_current_package_identity() {
3029            return Ok(local_app_data
3030                .join("Packages")
3031                .join(PACKAGED_FAMILY_NAME)
3032                .join("LocalState"));
3033        }
3034        Ok(local_app_data.join("Devolutions").join("Pinget"))
3035    }
3036
3037    #[cfg(not(windows))]
3038    {
3039        Ok(local_app_data.join("pinget"))
3040    }
3041}
3042
3043#[cfg(windows)]
3044fn has_current_package_identity() -> bool {
3045    // APPMODEL_ERROR_NO_PACKAGE; documented Win32 error returned when the calling
3046    // process has no AppX package identity.
3047    const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
3048
3049    let mut length: u32 = 0;
3050    // SAFETY: GetCurrentPackageFullName accepts a null name buffer when length is 0; it returns
3051    // ERROR_INSUFFICIENT_BUFFER for packaged callers and APPMODEL_ERROR_NO_PACKAGE otherwise.
3052    let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
3053    rc != APPMODEL_ERROR_NO_PACKAGE
3054}
3055
3056fn store_path(app_root: &Path) -> PathBuf {
3057    app_root.join("sources.json")
3058}
3059
3060fn user_settings_path(app_root: &Path) -> PathBuf {
3061    if uses_packaged_layout(app_root) {
3062        app_root.join("settings.json")
3063    } else {
3064        app_root.join("user-settings.json")
3065    }
3066}
3067
3068fn admin_settings_path(app_root: &Path) -> PathBuf {
3069    app_root.join("admin-settings.json")
3070}
3071
3072fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
3073    if uses_packaged_layout(app_root) {
3074        return app_root
3075            .join(packaged_source_type(source.kind))
3076            .join(sanitize_path_segment(&source.identifier));
3077    }
3078
3079    app_root.join("sources").join(sanitize_path_segment(&source.name))
3080}
3081
3082fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3083    source_state_dir(app_root, source).join("source.msix")
3084}
3085
3086fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3087    source_state_dir(app_root, source).join("index.db")
3088}
3089
3090fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3091    source_state_dir(app_root, source).join("rest-information.json")
3092}
3093
3094fn rest_manifest_cache_path_with_root(
3095    app_root: &Path,
3096    source: &SourceRecord,
3097    package_id: &str,
3098    version: &str,
3099    channel: &str,
3100) -> PathBuf {
3101    let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
3102    let digest = sha256_hex(key.as_bytes());
3103    cache_root(app_root)
3104        .join("REST_M")
3105        .join(sanitize_path_segment(&source.identifier))
3106        .join(format!("{digest}.json"))
3107}
3108
3109fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
3110    cache_root(app_root)
3111        .join(bucket)
3112        .join(sanitize_path_segment(identifier))
3113}
3114
3115fn load_store(app_root: &Path) -> Result<SourceStore> {
3116    if uses_packaged_layout(app_root)
3117        && let Some(store) = load_packaged_store(app_root)?
3118    {
3119        return Ok(store);
3120    }
3121
3122    let path = store_path(app_root);
3123    if !path.exists() {
3124        let store = SourceStore::default();
3125        save_store(app_root, &store)?;
3126        return Ok(store);
3127    }
3128
3129    let bytes = fs::read(path).context("failed to read source store")?;
3130    serde_json::from_slice(&bytes).context("failed to parse source store")
3131}
3132
3133fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
3134    if uses_system_winget_source_commands(app_root) {
3135        bail!("System WinGet source settings must be modified through winget.");
3136    }
3137
3138    if uses_packaged_layout(app_root) {
3139        return save_packaged_store(app_root, store);
3140    }
3141
3142    write_json(store_path(app_root), store)
3143}
3144
3145fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
3146    let user_sources_path = packaged_user_sources_path(app_root)?;
3147    let metadata_path = app_root.join("sources_metadata");
3148    let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
3149    let metadata_yaml = fs::read_to_string(&metadata_path).ok();
3150    load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
3151}
3152
3153fn load_packaged_store_from_streams(
3154    user_sources_yaml: Option<&str>,
3155    metadata_yaml: Option<&str>,
3156) -> Result<Option<SourceStore>> {
3157    if is_secure_settings_stub(user_sources_yaml) {
3158        return load_system_winget_source_store().map(Some);
3159    }
3160
3161    Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
3162}
3163
3164fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
3165    let user_sources_path = packaged_user_sources_path(app_root)?;
3166    if let Some(parent) = user_sources_path.parent() {
3167        fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
3168    }
3169
3170    fs::write(&user_sources_path, render_packaged_sources_yaml(store))
3171        .context("failed to write packaged user_sources stream")?;
3172    fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
3173        .context("failed to write packaged source metadata")?;
3174    Ok(())
3175}
3176
3177fn uses_system_winget_source_commands(app_root: &Path) -> bool {
3178    if !uses_packaged_layout(app_root) {
3179        return false;
3180    }
3181
3182    #[cfg(windows)]
3183    {
3184        let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
3185            return false;
3186        };
3187
3188        let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
3189        is_secure_settings_stub(user_sources_yaml.as_deref())
3190    }
3191
3192    #[cfg(not(windows))]
3193    {
3194        false
3195    }
3196}
3197
3198fn is_secure_settings_stub(value: Option<&str>) -> bool {
3199    value
3200        .map(str::trim_start)
3201        .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
3202}
3203
3204fn load_system_winget_source_store() -> Result<SourceStore> {
3205    let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
3206    Ok(SourceStore {
3207        sources: parse_system_winget_source_export(&result.stdout)?,
3208    })
3209}
3210
3211fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
3212    run_checked_winget_source_command(
3213        build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
3214        &format!("add WinGet source '{name}'"),
3215    )?;
3216    Ok(())
3217}
3218
3219fn system_winget_remove_source(name: &str) -> Result<()> {
3220    run_checked_winget_source_command(
3221        vec![
3222            "source".to_owned(),
3223            "remove".to_owned(),
3224            "--name".to_owned(),
3225            name.to_owned(),
3226            "--disable-interactivity".to_owned(),
3227        ],
3228        &format!("remove WinGet source '{name}'"),
3229    )?;
3230    Ok(())
3231}
3232
3233fn system_winget_reset_source(name: &str) -> Result<()> {
3234    run_checked_winget_source_command(
3235        vec![
3236            "source".to_owned(),
3237            "reset".to_owned(),
3238            "--name".to_owned(),
3239            name.to_owned(),
3240            "--force".to_owned(),
3241            "--disable-interactivity".to_owned(),
3242        ],
3243        &format!("reset WinGet source '{name}'"),
3244    )?;
3245    Ok(())
3246}
3247
3248fn system_winget_reset_sources() -> Result<()> {
3249    run_checked_winget_source_command(
3250        vec![
3251            "source".to_owned(),
3252            "reset".to_owned(),
3253            "--force".to_owned(),
3254            "--disable-interactivity".to_owned(),
3255        ],
3256        "reset WinGet sources",
3257    )?;
3258    Ok(())
3259}
3260
3261fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
3262    let mut args = vec!["source".to_owned(), "update".to_owned()];
3263    if let Some(name) = name {
3264        args.push("--name".to_owned());
3265        args.push(name.to_owned());
3266    }
3267
3268    args.push("--disable-interactivity".to_owned());
3269    let result = run_checked_winget_source_command(
3270        args,
3271        if name.is_some() {
3272            "update WinGet source"
3273        } else {
3274            "update WinGet sources"
3275        },
3276    )?;
3277    let detail = result.stdout.trim();
3278    Ok(if detail.is_empty() {
3279        "Done".to_owned()
3280    } else {
3281        detail.to_owned()
3282    })
3283}
3284
3285fn build_winget_source_export_arguments() -> Vec<String> {
3286    vec![
3287        "source".to_owned(),
3288        "export".to_owned(),
3289        "--disable-interactivity".to_owned(),
3290    ]
3291}
3292
3293fn build_winget_source_add_arguments(
3294    name: &str,
3295    arg: &str,
3296    kind: SourceKind,
3297    trust_level: &str,
3298    explicit: bool,
3299) -> Vec<String> {
3300    let mut args = vec![
3301        "source".to_owned(),
3302        "add".to_owned(),
3303        "--name".to_owned(),
3304        name.to_owned(),
3305        "--arg".to_owned(),
3306        arg.to_owned(),
3307        "--type".to_owned(),
3308        packaged_source_type(kind).to_owned(),
3309        "--disable-interactivity".to_owned(),
3310    ];
3311
3312    if !trust_level.trim().is_empty() {
3313        args.push("--trust-level".to_owned());
3314        args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
3315            "trusted".to_owned()
3316        } else {
3317            "none".to_owned()
3318        });
3319    }
3320
3321    if explicit {
3322        args.push("--explicit".to_owned());
3323    }
3324
3325    args
3326}
3327
3328#[derive(Debug)]
3329struct WingetSourceCommandResult {
3330    exit_code: i32,
3331    stdout: String,
3332    stderr: String,
3333}
3334
3335type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
3336
3337static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
3338    RwLock::new(run_winget_source_command_process);
3339
3340fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
3341    let result = run_winget_source_command(&args)?;
3342    if result.exit_code == 0 {
3343        return Ok(result);
3344    }
3345
3346    let detail = [result.stderr.trim(), result.stdout.trim()]
3347        .into_iter()
3348        .filter(|value| !value.is_empty())
3349        .collect::<Vec<_>>()
3350        .join("\n");
3351    if detail.is_empty() {
3352        bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
3353    }
3354
3355    bail!(
3356        "Failed to {action}; winget exited with code {}:\n{}",
3357        result.exit_code,
3358        detail
3359    );
3360}
3361
3362fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
3363    let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
3364        .read()
3365        .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
3366    runner(args)
3367}
3368
3369fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
3370    use std::process::Command;
3371
3372    let output = Command::new("winget")
3373        .args(args)
3374        .output()
3375        .context("failed to run winget source command")?;
3376
3377    Ok(WingetSourceCommandResult {
3378        exit_code: output.status.code().unwrap_or(-1),
3379        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3380        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3381    })
3382}
3383
3384fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
3385    let trimmed = output.trim();
3386    if trimmed.is_empty() {
3387        return Ok(Vec::new());
3388    }
3389
3390    if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
3391        && let Some(sources) = parse_system_winget_source_export_value(&value)?
3392    {
3393        return Ok(order_sources(sources));
3394    }
3395
3396    let mut sources = Vec::new();
3397    for line in output.replace("\r\n", "\n").lines().map(str::trim) {
3398        if line.is_empty() {
3399            continue;
3400        }
3401
3402        let value: JsonValue = serde_json::from_str(line)
3403            .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
3404        let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
3405            bail!("WinGet source export returned a non-source line: {line}");
3406        };
3407        sources.append(&mut parsed);
3408    }
3409
3410    Ok(order_sources(sources))
3411}
3412
3413fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
3414    match value {
3415        JsonValue::Array(items) => items
3416            .iter()
3417            .map(parse_system_winget_source_record)
3418            .collect::<Result<Vec<_>>>()
3419            .map(Some),
3420        JsonValue::Object(object) => {
3421            if let Some(JsonValue::Array(items)) = object.get("Sources") {
3422                items
3423                    .iter()
3424                    .map(parse_system_winget_source_record)
3425                    .collect::<Result<Vec<_>>>()
3426                    .map(Some)
3427            } else if object.contains_key("Name") {
3428                Ok(Some(vec![parse_system_winget_source_record(value)?]))
3429            } else {
3430                Ok(None)
3431            }
3432        }
3433        _ => Ok(None),
3434    }
3435}
3436
3437fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
3438    let object = value
3439        .as_object()
3440        .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
3441    let name = required_json_string(object, "Name")?;
3442    let source_type = required_json_string(object, "Type")?;
3443    let arg = required_json_string(object, "Arg")?;
3444    let kind = parse_system_winget_source_kind(&source_type)?;
3445    let identifier = optional_json_string(object, "Identifier")
3446        .or_else(|| optional_json_string(object, "Data"))
3447        .unwrap_or_else(|| name.clone());
3448
3449    Ok(SourceRecord {
3450        name,
3451        kind,
3452        arg,
3453        identifier,
3454        trust_level: parse_system_winget_trust_level(object),
3455        explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
3456        priority: optional_json_i32(object, "Priority").unwrap_or_default(),
3457        last_update: None,
3458        source_version: None,
3459    })
3460}
3461
3462fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
3463    optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
3464}
3465
3466fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
3467    match object.get(key)? {
3468        JsonValue::String(value) => Some(value.clone()),
3469        JsonValue::Number(value) => Some(value.to_string()),
3470        _ => None,
3471    }
3472}
3473
3474fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
3475    match object.get(key)? {
3476        JsonValue::Bool(value) => Some(*value),
3477        JsonValue::String(value) => value.parse().ok(),
3478        _ => None,
3479    }
3480}
3481
3482fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
3483    match object.get(key)? {
3484        JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
3485        JsonValue::String(value) => value.parse().ok(),
3486        _ => None,
3487    }
3488}
3489
3490fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
3491    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
3492        Ok(SourceKind::PreIndexed)
3493    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
3494        Ok(SourceKind::Rest)
3495    } else {
3496        bail!("Unsupported WinGet source type '{value}'.")
3497    }
3498}
3499
3500fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
3501    let Some(value) = object.get("TrustLevel") else {
3502        return "None".to_owned();
3503    };
3504
3505    match value {
3506        JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
3507        JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
3508        JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
3509        _ => "None".to_owned(),
3510    }
3511}
3512
3513fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
3514    value
3515        .as_str()
3516        .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
3517}
3518
3519fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
3520    sources.sort_by(|left, right| {
3521        left.explicit
3522            .cmp(&right.explicit)
3523            .then_with(|| right.priority.cmp(&left.priority))
3524            .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
3525    });
3526    sources
3527}
3528
3529fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
3530    if user_sources_yaml.is_none() && metadata_yaml.is_none() {
3531        return None;
3532    }
3533
3534    let mut sources = SourceStore::default().sources;
3535    for entry in parse_packaged_yaml_entries(user_sources_yaml) {
3536        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
3537            continue;
3538        };
3539
3540        if yaml_bool(entry.get("IsTombstone")) {
3541            sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
3542            continue;
3543        }
3544
3545        let Some(mapped) = map_packaged_source_entry(&entry) else {
3546            continue;
3547        };
3548
3549        if let Some(existing) = sources
3550            .iter_mut()
3551            .find(|source| source.name.eq_ignore_ascii_case(&name))
3552        {
3553            *existing = mapped;
3554        } else {
3555            sources.push(mapped);
3556        }
3557    }
3558
3559    for entry in parse_packaged_yaml_entries(metadata_yaml) {
3560        let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
3561            continue;
3562        };
3563
3564        let Some(source) = sources
3565            .iter_mut()
3566            .find(|source| source.name.eq_ignore_ascii_case(&name))
3567        else {
3568            continue;
3569        };
3570
3571        if let Some(last_update) = entry
3572            .get("LastUpdate")
3573            .and_then(|value| value.as_deref())
3574            .and_then(parse_packaged_datetime)
3575        {
3576            source.last_update = Some(last_update);
3577        }
3578
3579        if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
3580            && !source_version.is_empty()
3581        {
3582            source.source_version = Some(source_version);
3583        }
3584    }
3585
3586    Some(SourceStore { sources })
3587}
3588
3589fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
3590    let Some(yaml) = yaml else {
3591        return Vec::new();
3592    };
3593
3594    let mut entries = Vec::new();
3595    let mut current: Option<BTreeMap<String, Option<String>>> = None;
3596    for raw_line in yaml.replace("\r\n", "\n").split('\n') {
3597        let line = raw_line.trim_end();
3598        let trimmed = line.trim();
3599        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
3600            continue;
3601        }
3602
3603        if let Some(remainder) = trimmed.strip_prefix("- ") {
3604            if let Some(entry) = current.take() {
3605                entries.push(entry);
3606            }
3607
3608            let mut entry = BTreeMap::new();
3609            add_packaged_yaml_key_value(&mut entry, remainder);
3610            current = Some(entry);
3611            continue;
3612        }
3613
3614        if let Some(entry) = current.as_mut() {
3615            add_packaged_yaml_key_value(entry, trimmed);
3616        }
3617    }
3618
3619    if let Some(entry) = current {
3620        entries.push(entry);
3621    }
3622
3623    entries
3624}
3625
3626fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
3627    let Some((key, value)) = line.split_once(':') else {
3628        return;
3629    };
3630
3631    entry.insert(
3632        key.trim().to_owned(),
3633        Some(unquote_yaml_scalar(value.trim()).to_owned()),
3634    );
3635}
3636
3637fn unquote_yaml_scalar(value: &str) -> &str {
3638    if value.len() >= 2 {
3639        let bytes = value.as_bytes();
3640        let first = bytes[0] as char;
3641        let last = bytes[value.len() - 1] as char;
3642        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
3643            return &value[1..value.len() - 1];
3644        }
3645    }
3646
3647    value
3648}
3649
3650fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
3651    let name = entry.get("Name").and_then(|value| value.clone())?;
3652    let source_type = entry.get("Type").and_then(|value| value.clone())?;
3653    let arg = entry.get("Arg").and_then(|value| value.clone())?;
3654    let kind = parse_packaged_source_kind(&source_type)?;
3655
3656    Some(SourceRecord {
3657        name: name.clone(),
3658        kind,
3659        arg,
3660        identifier: entry
3661            .get("Identifier")
3662            .and_then(|value| value.clone())
3663            .or_else(|| entry.get("Data").and_then(|value| value.clone()))
3664            .unwrap_or(name),
3665        trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
3666        explicit: yaml_bool(entry.get("Explicit")),
3667        priority: entry
3668            .get("Priority")
3669            .and_then(|value| value.as_deref())
3670            .and_then(|value| value.parse::<i32>().ok())
3671            .unwrap_or_default(),
3672        last_update: None,
3673        source_version: None,
3674    })
3675}
3676
3677fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
3678    if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
3679        Some(SourceKind::PreIndexed)
3680    } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
3681        Some(SourceKind::Rest)
3682    } else {
3683        None
3684    }
3685}
3686
3687fn parse_packaged_trust_level(value: Option<&str>) -> String {
3688    match value {
3689        Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
3690        Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
3691        _ => "None".to_owned(),
3692    }
3693}
3694
3695fn yaml_bool(value: Option<&Option<String>>) -> bool {
3696    value
3697        .and_then(|raw| raw.as_deref())
3698        .and_then(|raw| raw.parse::<bool>().ok())
3699        .unwrap_or(false)
3700}
3701
3702fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
3703    if let Ok(unix_seconds) = value.parse::<i64>() {
3704        return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
3705    }
3706
3707    DateTime::parse_from_rfc3339(value)
3708        .ok()
3709        .map(|value| value.with_timezone(&Utc))
3710}
3711
3712fn render_packaged_sources_yaml(store: &SourceStore) -> String {
3713    let mut lines = vec!["Sources:".to_owned()];
3714    for source in &store.sources {
3715        lines.push(format!("  - Name: {}", source.name));
3716        lines.push(format!("    Type: {}", packaged_source_type(source.kind)));
3717        lines.push(format!("    Arg: {}", yaml_scalar(&source.arg)));
3718        lines.push(format!("    Data: {}", yaml_scalar(&source.identifier)));
3719        lines.push(format!(
3720            "    TrustLevel: {}",
3721            if source.trust_level.eq_ignore_ascii_case("Trusted") {
3722                1
3723            } else {
3724                0
3725            }
3726        ));
3727        lines.push(format!("    Explicit: {}", source.explicit));
3728        lines.push(format!("    Priority: {}", source.priority));
3729        lines.push("    IsTombstone: false".to_owned());
3730    }
3731    lines.push(String::new());
3732    lines.join("\n")
3733}
3734
3735fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
3736    let mut lines = vec!["Sources:".to_owned()];
3737    for source in &store.sources {
3738        if source.last_update.is_none() && source.source_version.is_none() {
3739            continue;
3740        }
3741
3742        lines.push(format!("  - Name: {}", source.name));
3743        if let Some(last_update) = source.last_update {
3744            lines.push(format!("    LastUpdate: {}", last_update.timestamp()));
3745        }
3746        if let Some(source_version) = &source.source_version {
3747            lines.push(format!("    SourceVersion: {}", yaml_scalar(source_version)));
3748        }
3749    }
3750    lines.push(String::new());
3751    lines.join("\n")
3752}
3753
3754fn yaml_scalar(value: &str) -> String {
3755    if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
3756        format!("\"{}\"", value.replace('"', "\\\""))
3757    } else {
3758        value.to_owned()
3759    }
3760}
3761
3762fn uses_packaged_layout(app_root: &Path) -> bool {
3763    #[cfg(windows)]
3764    {
3765        try_get_packaged_local_state_root(app_root).is_some()
3766    }
3767
3768    #[cfg(not(windows))]
3769    {
3770        let _ = app_root;
3771        false
3772    }
3773}
3774
3775#[cfg(windows)]
3776fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
3777    let local_state = app_root.file_name()?.to_string_lossy();
3778    if !local_state.eq_ignore_ascii_case("LocalState") {
3779        return None;
3780    }
3781
3782    let family = app_root.parent()?.file_name()?.to_string_lossy();
3783    if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
3784        return None;
3785    }
3786
3787    let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
3788    if !packages.eq_ignore_ascii_case("Packages") {
3789        return None;
3790    }
3791
3792    Some(app_root.to_path_buf())
3793}
3794
3795#[cfg(windows)]
3796fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
3797    let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
3798    Ok(packaged_secure_settings_root()?.join("user_sources"))
3799}
3800
3801#[cfg(not(windows))]
3802fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
3803    bail!("packaged WinGet source streams are only available on Windows")
3804}
3805
3806#[cfg(windows)]
3807fn packaged_secure_settings_root() -> Result<PathBuf> {
3808    let program_data = std::env::var_os("PROGRAMDATA")
3809        .map(PathBuf::from)
3810        .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
3811    Ok(program_data
3812        .join("Microsoft")
3813        .join("WinGet")
3814        .join(current_user_sid()?)
3815        .join("settings")
3816        .join("pkg")
3817        .join(PACKAGED_NAME))
3818}
3819
3820#[cfg(windows)]
3821#[allow(clippy::multiple_unsafe_ops_per_block)]
3822fn current_user_sid() -> Result<String> {
3823    fn close_token(token: HANDLE) {
3824        if !token.is_null() {
3825            // SAFETY: token is a HANDLE returned by OpenProcessToken and may be closed once here.
3826            unsafe {
3827                CloseHandle(token);
3828            }
3829        }
3830    }
3831
3832    let mut token: HANDLE = std::ptr::null_mut();
3833    // SAFETY: GetCurrentProcess returns a pseudo-handle valid for OpenProcessToken in this process.
3834    let current_process = unsafe { GetCurrentProcess() };
3835    // SAFETY: token points to valid storage for the process token handle on success.
3836    if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
3837        bail!("failed to open current process token")
3838    }
3839
3840    let mut length = 0;
3841    // SAFETY: The null buffer call asks Windows for the required buffer length.
3842    unsafe {
3843        GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
3844    }
3845
3846    let element_size = size_of::<TOKEN_USER>();
3847    let element_count = (length as usize).div_ceil(element_size);
3848    let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
3849    // SAFETY: buffer is aligned for TOKEN_USER and has at least length bytes of storage.
3850    if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
3851        close_token(token);
3852        bail!("failed to get current user token information")
3853    }
3854
3855    // SAFETY: GetTokenInformation initialized the buffer as a TOKEN_USER structure.
3856    let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
3857    let mut sid_ptr: *mut u16 = std::ptr::null_mut();
3858    // SAFETY: token_user.User.Sid is supplied by Windows and sid_ptr receives a LocalAlloc buffer on success.
3859    if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
3860        close_token(token);
3861        bail!("failed to convert current user SID")
3862    }
3863
3864    let mut len = 0usize;
3865    // SAFETY: sid_ptr is a null-terminated UTF-16 string returned by ConvertSidToStringSidW.
3866    while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
3867        len += 1;
3868    }
3869
3870    // SAFETY: sid_ptr points to len initialized UTF-16 code units.
3871    let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
3872    let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
3873    // SAFETY: sid_ptr was allocated by ConvertSidToStringSidW and must be released with LocalFree.
3874    unsafe {
3875        LocalFree(sid_ptr.cast());
3876    }
3877    close_token(token);
3878    Ok(sid)
3879}
3880
3881fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
3882    app_root.join("Microsoft").join("Windows Package Manager")
3883}
3884
3885fn cache_root(app_root: &Path) -> PathBuf {
3886    if uses_packaged_layout(app_root) {
3887        packaged_file_cache_root(app_root)
3888    } else {
3889        default_cache_root_fallback()
3890    }
3891}
3892
3893fn default_cache_root_fallback() -> PathBuf {
3894    std::env::temp_dir().join("cache")
3895}
3896
3897fn packaged_source_type(kind: SourceKind) -> &'static str {
3898    match kind {
3899        SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
3900        SourceKind::Rest => "Microsoft.Rest",
3901    }
3902}
3903
3904fn sanitize_path_segment(value: &str) -> String {
3905    value
3906        .chars()
3907        .map(|ch| match ch {
3908            '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
3909            _ => ch,
3910        })
3911        .collect()
3912}
3913
3914fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
3915    if !path.exists() {
3916        return Ok(JsonMap::new());
3917    }
3918
3919    let bytes = fs::read(path).context("failed to read JSON file")?;
3920    let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
3921    Ok(match value {
3922        JsonValue::Object(object) => object,
3923        _ => JsonMap::new(),
3924    })
3925}
3926
3927fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
3928    write_json(path, value)
3929}
3930
3931fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
3932    if let Some(parent) = path.parent() {
3933        fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
3934    }
3935
3936    let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
3937    fs::write(path, bytes).context("failed to write JSON file")
3938}
3939
3940fn merge_json_objects(
3941    current: &JsonMap<String, JsonValue>,
3942    update: &JsonMap<String, JsonValue>,
3943) -> JsonMap<String, JsonValue> {
3944    let mut merged = current.clone();
3945    for (key, value) in update {
3946        match (merged.get(key), value) {
3947            (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
3948                merged.insert(
3949                    key.clone(),
3950                    JsonValue::Object(merge_json_objects(current_object, update_object)),
3951                );
3952            }
3953            _ => {
3954                merged.insert(key.clone(), value.clone());
3955            }
3956        }
3957    }
3958
3959    merged
3960}
3961
3962fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
3963    match expected {
3964        JsonValue::Object(expected_object) => match current {
3965            JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
3966                current_object
3967                    .get(key)
3968                    .is_some_and(|current_child| json_contains(current_child, expected_child))
3969            }),
3970            _ => false,
3971        },
3972        JsonValue::Array(expected_array) => match current {
3973            JsonValue::Array(current_array) => {
3974                current_array.len() == expected_array.len()
3975                    && current_array
3976                        .iter()
3977                        .zip(expected_array.iter())
3978                        .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
3979            }
3980            _ => false,
3981        },
3982        _ => current == expected,
3983    }
3984}
3985
3986fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
3987    match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
3988        "none" | "default" => Ok("None".to_owned()),
3989        "trusted" => Ok("Trusted".to_owned()),
3990        other => bail!("unsupported source trust level: {other}"),
3991    }
3992}
3993
3994fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
3995    SUPPORTED_ADMIN_SETTINGS
3996        .iter()
3997        .copied()
3998        .find(|setting| setting.eq_ignore_ascii_case(name))
3999        .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
4000}
4001
4002fn default_source_trust_level() -> String {
4003    "None".to_owned()
4004}
4005
4006fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
4007where
4008    F: FnMut(&SqlRow<'_>) -> Result<T>,
4009{
4010    let mut statement = connection
4011        .prepare(sql)
4012        .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4013    let mut rows = statement
4014        .query(params_from_iter(params))
4015        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4016    let mut result = Vec::new();
4017
4018    while let Some(row) = rows.next().context("failed to read SQL row")? {
4019        result.push(map(row)?);
4020    }
4021
4022    Ok(result)
4023}
4024
4025fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
4026where
4027    F: FnOnce(&SqlRow<'_>) -> Result<T>,
4028{
4029    let mut statement = connection
4030        .prepare(sql)
4031        .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4032    let mut rows = statement
4033        .query(params_from_iter(params))
4034        .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4035
4036    match rows.next().context("failed to read SQL row")? {
4037        Some(row) => Ok(Some(map(row)?)),
4038        None => Ok(None),
4039    }
4040}
4041
4042fn row_ref<'a>(row: &'a SqlRow<'_>, index: usize) -> Result<ValueRef<'a>> {
4043    row.get_ref(index)
4044        .with_context(|| format!("failed to read SQL column {index}"))
4045}
4046
4047fn row_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4048    match row_ref(row, index)? {
4049        ValueRef::Text(value) => std::str::from_utf8(value)
4050            .context("SQL text column was not valid UTF-8")
4051            .map(str::to_owned),
4052        ValueRef::Integer(value) => Ok(value.to_string()),
4053        ValueRef::Real(value) => Ok(value.to_string()),
4054        ValueRef::Blob(value) => String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8"),
4055        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4056    }
4057}
4058
4059fn row_opt_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4060    match row_ref(row, index)? {
4061        ValueRef::Null => Ok(None),
4062        ValueRef::Text(value) => Ok(Some(
4063            std::str::from_utf8(value)
4064                .context("SQL text column was not valid UTF-8")?
4065                .to_owned(),
4066        )),
4067        ValueRef::Integer(value) => Ok(Some(value.to_string())),
4068        ValueRef::Real(value) => Ok(Some(value.to_string())),
4069        ValueRef::Blob(value) => Ok(Some(
4070            String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8")?,
4071        )),
4072    }
4073}
4074
4075fn row_i64(row: &SqlRow<'_>, index: usize) -> Result<i64> {
4076    match row_ref(row, index)? {
4077        ValueRef::Integer(value) => Ok(value),
4078        ValueRef::Text(value) => std::str::from_utf8(value)
4079            .context("SQL text column was not valid UTF-8")?
4080            .parse::<i64>()
4081            .with_context(|| format!("failed to parse integer from SQL text column {index}")),
4082        ValueRef::Real(value) => f64_to_i64_checked(value, index),
4083        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4084        ValueRef::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
4085    }
4086}
4087
4088fn row_hex_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4089    match row_ref(row, index)? {
4090        ValueRef::Blob(value) => Ok(bytes_to_hex(value)),
4091        ValueRef::Text(value) => Ok(std::str::from_utf8(value)
4092            .context("SQL text column was not valid UTF-8")?
4093            .to_ascii_lowercase()),
4094        ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4095        ValueRef::Integer(value) => Ok(format!("{value:x}")),
4096        ValueRef::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
4097    }
4098}
4099
4100fn row_opt_hex_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4101    match row_ref(row, index)? {
4102        ValueRef::Null => Ok(None),
4103        ValueRef::Blob(value) => Ok(Some(bytes_to_hex(value))),
4104        ValueRef::Text(value) => Ok(Some(
4105            std::str::from_utf8(value)
4106                .context("SQL text column was not valid UTF-8")?
4107                .to_ascii_lowercase(),
4108        )),
4109        ValueRef::Integer(value) => Ok(Some(format!("{value:x}"))),
4110        ValueRef::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
4111    }
4112}
4113
4114fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
4115    if !value.is_finite() {
4116        bail!("SQL column {index} contained a non-finite floating-point value");
4117    }
4118
4119    if value.fract() != 0.0 {
4120        bail!("SQL column {index} contained a non-integer floating-point value");
4121    }
4122
4123    if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
4124        bail!("SQL column {index} contained a floating-point value outside the i64 range");
4125    }
4126
4127    format!("{value:.0}")
4128        .parse::<i64>()
4129        .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
4130}
4131
4132fn bytes_to_hex(bytes: &[u8]) -> String {
4133    let mut result = String::with_capacity(bytes.len() * 2);
4134    for byte in bytes {
4135        result.push_str(&format!("{byte:02x}"));
4136    }
4137    result
4138}
4139
4140fn query_v2_matches(
4141    connection: &Connection,
4142    query: &PackageQuery,
4143    semantics: SearchSemantics,
4144) -> Result<(Vec<V2SearchRow>, bool)> {
4145    let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
4146    let limit = source_fetch_results(query, semantics);
4147    let sql = format!(
4148        "SELECT rowid, id, name, moniker, latest_version, hash \
4149         FROM packages WHERE {where_clause} LIMIT {}",
4150        limit + 1
4151    );
4152    let mut rows = query_rows(
4153        connection,
4154        &sql,
4155        params.into_iter().map(SqlValue::Text).collect(),
4156        |row| {
4157            Ok(V2SearchRow {
4158                package_rowid: row_i64(row, 0)?,
4159                id: row_string(row, 1)?,
4160                name: row_string(row, 2)?,
4161                moniker: row_opt_string(row, 3)?,
4162                version: row_string(row, 4)?,
4163                package_hash: row_hex_string(row, 5)?,
4164                match_criteria: None,
4165            })
4166        },
4167    )?;
4168    for row in &mut rows {
4169        row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
4170    }
4171    let truncated = rows.len() > limit;
4172    rows.truncate(limit);
4173    Ok((rows, truncated))
4174}
4175
4176fn query_v1_matches(
4177    connection: &Connection,
4178    query: &PackageQuery,
4179    semantics: SearchSemantics,
4180) -> Result<(Vec<V1SearchRow>, bool)> {
4181    let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
4182    let limit = source_fetch_results(query, semantics);
4183    let sql = format!(
4184        "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
4185         FROM manifest \
4186         JOIN ids ON manifest.id = ids.rowid \
4187         JOIN names ON manifest.name = names.rowid \
4188         LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
4189         JOIN versions ON manifest.version = versions.rowid \
4190         JOIN channels ON manifest.channel = channels.rowid \
4191         WHERE {where_clause} LIMIT {}",
4192        limit + 1
4193    );
4194    let mut rows = query_rows(
4195        connection,
4196        &sql,
4197        params.into_iter().map(SqlValue::Text).collect(),
4198        |row| {
4199            Ok(V1SearchRow {
4200                manifest_rowid: row_i64(row, 0)?,
4201                package_rowid: row_i64(row, 1)?,
4202                version: row_string(row, 2)?,
4203                channel: row_string(row, 3)?,
4204                id: row_string(row, 4)?,
4205                name: row_string(row, 5)?,
4206                moniker: row_opt_string(row, 6)?,
4207                match_criteria: None,
4208            })
4209        },
4210    )?;
4211    for row in &mut rows {
4212        row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
4213    }
4214    let truncated = rows.len() > limit;
4215    rows.truncate(limit);
4216    Ok((rows, truncated))
4217}
4218
4219fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
4220    let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
4221
4222    for row in rows {
4223        match grouped.get(&row.package_rowid) {
4224            Some(existing)
4225                if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
4226                    != Ordering::Less => {}
4227            _ => {
4228                grouped.insert(row.package_rowid, row);
4229            }
4230        }
4231    }
4232
4233    grouped.into_values().collect()
4234}
4235
4236fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
4237    let has_hash = table_has_column(connection, "manifest", "hash")?;
4238    let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
4239    let sql = format!(
4240        "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
4241         FROM manifest \
4242         JOIN versions ON manifest.version = versions.rowid \
4243         JOIN channels ON manifest.channel = channels.rowid \
4244         WHERE manifest.id = ?1"
4245    );
4246    query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
4247        Ok(V1VersionRow {
4248            version: row_string(row, 0)?,
4249            channel: row_string(row, 1)?,
4250            pathpart_id: row_i64(row, 2)?,
4251            manifest_hash: row_opt_hex_string(row, 3)?,
4252        })
4253    })
4254}
4255
4256fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
4257    let mut parts = Vec::new();
4258    let mut current = Some(pathpart_id);
4259
4260    while let Some(id) = current {
4261        let (parent, pathpart) = query_optional_value(
4262            connection,
4263            "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
4264            vec![SqlValue::Integer(id)],
4265            |row| {
4266                Ok((
4267                    row_opt_string(row, 0)?
4268                        .and_then(|value| value.parse::<i64>().ok())
4269                        .filter(|value| *value != 0),
4270                    row_string(row, 1)?,
4271                ))
4272            },
4273        )?
4274        .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
4275        parts.push(pathpart);
4276        current = parent;
4277    }
4278
4279    parts.reverse();
4280    Ok(parts.join("/"))
4281}
4282
4283fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
4284    format!(
4285        "packages/{}/{}/versionData.mszyml",
4286        package_id,
4287        package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
4288    )
4289}
4290
4291fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
4292    let message = format!("{error:#}").to_ascii_lowercase();
4293    message.contains("no such table") && message.contains("packages")
4294}
4295
4296fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
4297    for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
4298        row_string(row, 1)
4299    })? {
4300        if value.eq_ignore_ascii_case(column) {
4301            return Ok(true);
4302        }
4303    }
4304
4305    Ok(false)
4306}
4307
4308fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
4309    let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
4310    let id_column = if v2 { "id" } else { "ids.id" };
4311    let name_column = if v2 { "name" } else { "names.name" };
4312    let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
4313    let exact_match = query.exact || semantics == SearchSemantics::Single;
4314    let mut params = Vec::new();
4315
4316    if let Some(value) = &query.id {
4317        return (
4318            single_field_condition(id_column, value, exact_match, &mut params),
4319            params,
4320        );
4321    }
4322    if let Some(value) = &query.name {
4323        return (
4324            single_field_condition(name_column, value, exact_match, &mut params),
4325            params,
4326        );
4327    }
4328    if let Some(value) = &query.moniker {
4329        return (
4330            single_field_condition(moniker_column, value, exact_match, &mut params),
4331            params,
4332        );
4333    }
4334    if let Some(value) = &query.tag {
4335        return (
4336            mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
4337            params,
4338        );
4339    }
4340    if let Some(value) = &query.command {
4341        return (
4342            mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
4343            params,
4344        );
4345    }
4346    if let Some(value) = &query.query {
4347        if exact_match {
4348            let conditions = [
4349                single_field_condition(id_column, value, true, &mut params),
4350                single_field_condition(name_column, value, true, &mut params),
4351                single_field_condition(moniker_column, value, true, &mut params),
4352            ];
4353            return (format!("({})", conditions.join(" OR ")), params);
4354        }
4355
4356        let mut conditions = vec![
4357            single_field_condition(id_column, value, false, &mut params),
4358            single_field_condition(name_column, value, false, &mut params),
4359            single_field_condition(moniker_column, value, false, &mut params),
4360        ];
4361        if semantics == SearchSemantics::Many {
4362            conditions.push(mapped_field_condition(
4363                v2,
4364                "tag",
4365                value,
4366                rowid_column,
4367                false,
4368                &mut params,
4369            ));
4370            conditions.push(mapped_field_condition(
4371                v2,
4372                "command",
4373                value,
4374                rowid_column,
4375                false,
4376                &mut params,
4377            ));
4378        }
4379        return (format!("({})", conditions.join(" OR ")), params);
4380    }
4381
4382    ("1 = 1".to_owned(), Vec::new())
4383}
4384
4385fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
4386    params.push(match_parameter(value, exact));
4387    format!("{column} LIKE ?{}", params.len())
4388}
4389
4390fn mapped_field_condition(
4391    v2: bool,
4392    value_name: &str,
4393    value: &str,
4394    rowid_column: &str,
4395    exact: bool,
4396    params: &mut Vec<String>,
4397) -> String {
4398    let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
4399        (
4400            format!("{value_name}s2"),
4401            format!("{value_name}s2_map"),
4402            value_name.to_owned(),
4403            "package".to_owned(),
4404        )
4405    } else {
4406        (
4407            format!("{value_name}s"),
4408            format!("{value_name}s_map"),
4409            value_name.to_owned(),
4410            "manifest".to_owned(),
4411        )
4412    };
4413    params.push(match_parameter(value, exact));
4414    let parameter = params.len();
4415    format!(
4416        "EXISTS (SELECT 1 FROM {map_table_name} JOIN {table_name} ON \
4417         {map_table_name}.{value_name} = {table_name}.rowid \
4418         WHERE {map_table_name}.{map_owner_column} = {rowid_column} \
4419         AND {table_name}.{map_value_column} LIKE ?{parameter})"
4420    )
4421}
4422
4423fn match_parameter(value: &str, exact: bool) -> String {
4424    if exact { value.to_owned() } else { format!("%{value}%") }
4425}
4426
4427fn build_rest_search_body(
4428    query: &PackageQuery,
4429    info: &RestInformation,
4430    semantics: SearchSemantics,
4431) -> Result<JsonValue> {
4432    let mut root = serde_json::Map::new();
4433    root.insert(
4434        "MaximumResults".to_owned(),
4435        JsonValue::from(source_fetch_results(query, semantics) as u64),
4436    );
4437    let exact_match = query.exact || semantics == SearchSemantics::Single;
4438
4439    if let Some(value) = &query.query {
4440        if semantics == SearchSemantics::Single {
4441            let mut filters = vec![
4442                rest_filter("PackageIdentifier", value, true),
4443                rest_filter("PackageName", value, true),
4444                rest_filter("Moniker", value, true),
4445            ];
4446            append_required_rest_filters(&mut filters, info);
4447            root.insert("Filters".to_owned(), JsonValue::Array(filters));
4448            return Ok(JsonValue::Object(root));
4449        }
4450
4451        root.insert(
4452            "Query".to_owned(),
4453            serde_json::json!({
4454                "KeyWord": value,
4455                "MatchType": if exact_match { "Exact" } else { "Substring" },
4456            }),
4457        );
4458    }
4459
4460    let mut filters = Vec::new();
4461    if let Some(value) = &query.id {
4462        filters.push(rest_filter("PackageIdentifier", value, exact_match));
4463    }
4464    if let Some(value) = &query.name {
4465        filters.push(rest_filter("PackageName", value, exact_match));
4466    }
4467    if let Some(value) = &query.moniker {
4468        filters.push(rest_filter("Moniker", value, exact_match));
4469    }
4470    if let Some(value) = &query.tag {
4471        filters.push(rest_filter("Tag", value, true));
4472    }
4473    if let Some(value) = &query.command {
4474        filters.push(rest_filter("Command", value, true));
4475    }
4476    append_required_rest_filters(&mut filters, info);
4477
4478    if !filters.is_empty() {
4479        root.insert("Filters".to_owned(), JsonValue::Array(filters));
4480    }
4481
4482    Ok(JsonValue::Object(root))
4483}
4484
4485fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
4486    if info
4487        .required_package_match_fields
4488        .iter()
4489        .any(|field| field.eq_ignore_ascii_case("Market"))
4490    {
4491        filters.push(rest_filter("Market", &default_market(), true));
4492    }
4493}
4494
4495fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
4496    serde_json::json!({
4497        "PackageMatchField": field,
4498        "RequestMatch": {
4499            "KeyWord": value,
4500            "MatchType": if exact { "Exact" } else { "Substring" },
4501        }
4502    })
4503}
4504
4505fn max_results(query: &PackageQuery) -> usize {
4506    query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
4507}
4508
4509fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
4510    match semantics {
4511        SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
4512        SearchSemantics::Single => max_results(query),
4513    }
4514}
4515
4516fn infer_preindexed_match_criteria_v2(
4517    connection: &Connection,
4518    row: &V2SearchRow,
4519    query: &PackageQuery,
4520    semantics: SearchSemantics,
4521) -> Result<Option<String>> {
4522    infer_match_criteria(
4523        &row.id,
4524        &row.name,
4525        row.moniker.as_deref(),
4526        query,
4527        semantics,
4528        |value| {
4529            find_mapped_value_v2(
4530                connection,
4531                "tags2",
4532                "tags2_map",
4533                "tag",
4534                row.package_rowid,
4535                value,
4536                query.exact,
4537            )
4538        },
4539        |value| {
4540            find_mapped_value_v2(
4541                connection,
4542                "commands2",
4543                "commands2_map",
4544                "command",
4545                row.package_rowid,
4546                value,
4547                query.exact,
4548            )
4549        },
4550    )
4551}
4552
4553fn infer_preindexed_match_criteria_v1(
4554    connection: &Connection,
4555    row: &V1SearchRow,
4556    query: &PackageQuery,
4557    semantics: SearchSemantics,
4558) -> Result<Option<String>> {
4559    infer_match_criteria(
4560        &row.id,
4561        &row.name,
4562        row.moniker.as_deref(),
4563        query,
4564        semantics,
4565        |value| {
4566            find_mapped_value_v1(
4567                connection,
4568                "tags",
4569                "tags_map",
4570                "tag",
4571                row.manifest_rowid,
4572                value,
4573                query.exact,
4574            )
4575        },
4576        |value| {
4577            find_mapped_value_v1(
4578                connection,
4579                "commands",
4580                "commands_map",
4581                "command",
4582                row.manifest_rowid,
4583                value,
4584                query.exact,
4585            )
4586        },
4587    )
4588}
4589
4590fn infer_match_criteria<FTag, FCommand>(
4591    id: &str,
4592    name: &str,
4593    moniker: Option<&str>,
4594    query: &PackageQuery,
4595    semantics: SearchSemantics,
4596    find_tag: FTag,
4597    find_command: FCommand,
4598) -> Result<Option<String>>
4599where
4600    FTag: FnOnce(&str) -> Result<Option<String>>,
4601    FCommand: FnOnce(&str) -> Result<Option<String>>,
4602{
4603    if let Some(value) = &query.tag {
4604        return Ok(Some(format_match_criteria("Tag", value)));
4605    }
4606    if let Some(value) = &query.command {
4607        return Ok(Some(format_match_criteria("Command", value)));
4608    }
4609    if let Some(value) = &query.moniker {
4610        return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
4611    }
4612    if semantics == SearchSemantics::Single {
4613        return Ok(None);
4614    }
4615    if let Some(value) = &query.query {
4616        if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
4617            return Ok(None);
4618        }
4619        if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
4620            return Ok(Some(format_match_criteria("Moniker", moniker_value)));
4621        }
4622        if let Some(tag) = find_tag(value)? {
4623            return Ok(Some(format_match_criteria("Tag", &tag)));
4624        }
4625        if let Some(command) = find_command(value)? {
4626            return Ok(Some(format_match_criteria("Command", &command)));
4627        }
4628    }
4629    Ok(None)
4630}
4631
4632fn find_mapped_value_v2(
4633    connection: &Connection,
4634    table_name: &str,
4635    map_table_name: &str,
4636    value_name: &str,
4637    package_rowid: i64,
4638    query: &str,
4639    exact: bool,
4640) -> Result<Option<String>> {
4641    let sql = format!(
4642        "SELECT {table_name}.{value_name} FROM {map_table_name} \
4643         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
4644         WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
4645    );
4646    let values = query_rows(
4647        connection,
4648        &sql,
4649        vec![
4650            SqlValue::Integer(package_rowid),
4651            SqlValue::Text(match_parameter(query, exact)),
4652        ],
4653        |row| row_string(row, 0),
4654    )?;
4655    Ok(select_best_text_match(values, query, exact))
4656}
4657
4658fn find_mapped_value_v1(
4659    connection: &Connection,
4660    table_name: &str,
4661    map_table_name: &str,
4662    value_name: &str,
4663    manifest_rowid: i64,
4664    query: &str,
4665    exact: bool,
4666) -> Result<Option<String>> {
4667    let sql = format!(
4668        "SELECT {table_name}.{value_name} FROM {map_table_name} \
4669         JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
4670         WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
4671    );
4672    let values = query_rows(
4673        connection,
4674        &sql,
4675        vec![
4676            SqlValue::Integer(manifest_rowid),
4677            SqlValue::Text(match_parameter(query, exact)),
4678        ],
4679        |row| row_string(row, 0),
4680    )?;
4681    Ok(select_best_text_match(values, query, exact))
4682}
4683
4684fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
4685    if let Some(value) = &query.tag {
4686        return Some(format_match_criteria("Tag", value));
4687    }
4688    if let Some(value) = &query.command {
4689        return Some(format_match_criteria("Command", value));
4690    }
4691    if let Some(value) = &query.moniker {
4692        return Some(format_match_criteria("Moniker", value));
4693    }
4694    if semantics == SearchSemantics::Single {
4695        return None;
4696    }
4697    let value = query.query.as_deref()?;
4698    if json_string(item, "PackageIdentifier")
4699        .as_deref()
4700        .is_some_and(|candidate| matches_text(candidate, value, query.exact))
4701        || json_string(item, "PackageName")
4702            .as_deref()
4703            .is_some_and(|candidate| matches_text(candidate, value, query.exact))
4704    {
4705        return None;
4706    }
4707    json_string(item, "Moniker")
4708        .filter(|candidate| matches_text(candidate, value, query.exact))
4709        .map(|candidate| format_match_criteria("Moniker", &candidate))
4710}
4711
4712fn format_match_criteria(field: &str, value: &str) -> String {
4713    format!("{field}: {value}")
4714}
4715
4716fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
4717    if exact {
4718        candidate.eq_ignore_ascii_case(query)
4719    } else {
4720        candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
4721    }
4722}
4723
4724fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
4725    let mut score = 0;
4726
4727    if let Some(value) = &query.query {
4728        score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
4729        score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
4730        if let Some(moniker) = candidate.moniker.as_deref() {
4731            score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
4732        }
4733    }
4734
4735    if let Some(value) = &query.id {
4736        score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
4737    }
4738    if let Some(value) = &query.name {
4739        score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
4740    }
4741    if let Some(value) = &query.moniker
4742        && let Some(moniker) = candidate.moniker.as_deref()
4743    {
4744        score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
4745    }
4746    if let Some(value) = &query.tag
4747        && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
4748    {
4749        score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
4750    }
4751    if let Some(value) = &query.command
4752        && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
4753    {
4754        score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
4755    }
4756
4757    if matches!(candidate.source_kind, SourceKind::PreIndexed) {
4758        score += 5;
4759    }
4760    if search_match_has_unknown_version(candidate) {
4761        score = score.saturating_sub(10);
4762    }
4763
4764    score
4765}
4766
4767fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
4768    candidate
4769        .version
4770        .as_deref()
4771        .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
4772}
4773
4774fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
4775    if manifest_path.is_file() {
4776        return Ok(manifest_path.to_path_buf());
4777    }
4778
4779    if !manifest_path.is_dir() {
4780        bail!("Manifest path not found: {}", manifest_path.display());
4781    }
4782
4783    let candidate = fs::read_dir(manifest_path)?
4784        .filter_map(|entry| entry.ok())
4785        .map(|entry| entry.path())
4786        .filter(|path| {
4787            path.extension()
4788                .and_then(|value| value.to_str())
4789                .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
4790        })
4791        .min();
4792
4793    candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
4794}
4795
4796fn score_text_match(
4797    candidate: &str,
4798    query: &str,
4799    exact: bool,
4800    exact_score: usize,
4801    prefix_score: usize,
4802    substring_score: usize,
4803) -> usize {
4804    if candidate.eq_ignore_ascii_case(query) {
4805        return exact_score;
4806    }
4807    if exact {
4808        return 0;
4809    }
4810
4811    let candidate_lower = candidate.to_ascii_lowercase();
4812    let query_lower = query.to_ascii_lowercase();
4813    if candidate_lower.starts_with(&query_lower) {
4814        prefix_score
4815    } else if candidate_lower.contains(&query_lower) {
4816        substring_score
4817    } else {
4818        0
4819    }
4820}
4821
4822fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
4823where
4824    I: IntoIterator<Item = String>,
4825{
4826    values
4827        .into_iter()
4828        .filter_map(|candidate| {
4829            let score = score_text_match(&candidate, query, exact, 3, 2, 1);
4830            (score > 0).then_some((score, candidate))
4831        })
4832        .max_by(|(left_score, left_value), (right_score, right_value)| {
4833            left_score
4834                .cmp(right_score)
4835                .then_with(|| right_value.len().cmp(&left_value.len()))
4836                .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
4837        })
4838        .map(|(_, candidate)| candidate)
4839}
4840
4841fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
4842    let (field, value) = criteria.split_once(": ")?;
4843    Some((field, value))
4844}
4845
4846fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
4847    let versions = item
4848        .get("Versions")
4849        .and_then(JsonValue::as_array)
4850        .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
4851    let mut result = Vec::new();
4852    for version in versions {
4853        let value = json_string(version, "PackageVersion")
4854            .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
4855        let channel = json_string(version, "Channel").unwrap_or_default();
4856        result.push(VersionKey {
4857            version: value,
4858            channel,
4859        });
4860    }
4861    Ok(result)
4862}
4863
4864fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
4865    let mut merged = YamlMapping::new();
4866    let mut documents = Vec::new();
4867    for document in serde_yaml::Deserializer::from_slice(bytes) {
4868        let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
4869        documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
4870        if let Some(mapping) = value.as_mapping() {
4871            merge_yaml_mapping(&mut merged, mapping);
4872        }
4873    }
4874
4875    let id = yaml_string_from_root(&merged, "PackageIdentifier")
4876        .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
4877    let version =
4878        yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
4879    let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
4880
4881    let installers = parse_yaml_installers(&merged);
4882
4883    Ok((
4884        Manifest {
4885            id,
4886            name,
4887            version,
4888            channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
4889            publisher: yaml_localized_string(&merged, "Publisher"),
4890            description: yaml_localized_string(&merged, "Description")
4891                .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
4892            moniker: yaml_string_from_root(&merged, "Moniker"),
4893            package_url: yaml_localized_string(&merged, "PackageUrl"),
4894            publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
4895            publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
4896            license: yaml_localized_string(&merged, "License"),
4897            license_url: yaml_localized_string(&merged, "LicenseUrl"),
4898            privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
4899            author: yaml_localized_string(&merged, "Author"),
4900            copyright: yaml_localized_string(&merged, "Copyright"),
4901            copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
4902            release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
4903            release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
4904            tags: yaml_string_list(&merged, "Tags"),
4905            agreements: yaml_agreement_list(&merged),
4906            package_dependencies: yaml_package_dependencies(&merged),
4907            documentation: yaml_documentation_list(&merged),
4908            installers,
4909        },
4910        collapse_structured_document(&JsonValue::Array(documents)),
4911    ))
4912}
4913
4914fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
4915    parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
4916}
4917
4918fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
4919    let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
4920    let data = root
4921        .get("Data")
4922        .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
4923    let versions = data
4924        .get("Versions")
4925        .and_then(JsonValue::as_array)
4926        .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
4927    let selected = versions
4928        .iter()
4929        .find(|item| {
4930            json_string(item, "PackageVersion").as_deref() == Some(version)
4931                && json_string(item, "Channel").unwrap_or_default() == channel
4932        })
4933        .or_else(|| versions.first())
4934        .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
4935
4936    let default_locale = selected
4937        .get("DefaultLocale")
4938        .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
4939    let name = json_string(default_locale, "PackageName")
4940        .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
4941    let installer_switch_defaults = json_installer_switches(selected);
4942    let top_platforms = json_string_list(selected, "Platform");
4943    let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
4944
4945    let installers = selected
4946        .get("Installers")
4947        .and_then(JsonValue::as_array)
4948        .map(|items| {
4949            items
4950                .iter()
4951                .map(|item| Installer {
4952                    platforms: {
4953                        let platforms = json_string_list(item, "Platform");
4954                        if platforms.is_empty() {
4955                            top_platforms.clone()
4956                        } else {
4957                            platforms
4958                        }
4959                    },
4960                    minimum_os_version: json_string(item, "MinimumOSVersion")
4961                        .or_else(|| top_minimum_os_version.clone()),
4962                    architecture: json_string(item, "Architecture"),
4963                    installer_type: json_string(item, "InstallerType"),
4964                    url: json_string(item, "InstallerUrl"),
4965                    sha256: json_string(item, "InstallerSha256"),
4966                    product_code: json_string(item, "ProductCode"),
4967                    locale: json_string(item, "InstallerLocale"),
4968                    scope: json_string(item, "Scope"),
4969                    release_date: json_string(item, "ReleaseDate"),
4970                    package_family_name: json_string(item, "PackageFamilyName"),
4971                    upgrade_code: json_string(item, "UpgradeCode"),
4972                    switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
4973                    commands: json_string_list(item, "Commands"),
4974                    package_dependencies: json_package_dependencies(item),
4975                })
4976                .collect::<Vec<_>>()
4977        })
4978        .unwrap_or_default();
4979
4980    let manifest = Manifest {
4981        id: package_id.to_owned(),
4982        name,
4983        version: version.to_owned(),
4984        channel: channel.to_owned(),
4985        publisher: json_string(default_locale, "Publisher"),
4986        description: json_string(default_locale, "Description")
4987            .or_else(|| json_string(default_locale, "ShortDescription")),
4988        moniker: json_string(default_locale, "Moniker"),
4989        package_url: json_string(default_locale, "PackageUrl"),
4990        publisher_url: json_string(default_locale, "PublisherUrl"),
4991        publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
4992        license: json_string(default_locale, "License"),
4993        license_url: json_string(default_locale, "LicenseUrl"),
4994        privacy_url: json_string(default_locale, "PrivacyUrl"),
4995        author: json_string(default_locale, "Author"),
4996        copyright: json_string(default_locale, "Copyright"),
4997        copyright_url: json_string(default_locale, "CopyrightUrl"),
4998        release_notes: json_string(default_locale, "ReleaseNotes"),
4999        release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
5000        tags: json_string_list(default_locale, "Tags"),
5001        agreements: json_agreement_list(default_locale),
5002        package_dependencies: json_package_dependencies(selected),
5003        documentation: json_documentation_list(default_locale),
5004        installers,
5005    };
5006
5007    Ok((
5008        manifest,
5009        build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
5010    ))
5011}
5012
5013fn collapse_structured_document(document: &JsonValue) -> JsonValue {
5014    match document {
5015        JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
5016            merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
5017        }
5018        JsonValue::Array(documents) => merge_manifest_documents(documents),
5019        JsonValue::Object(_) if is_merged_manifest_document(document) => {
5020            merge_manifest_documents(&split_merged_manifest_document(document))
5021        }
5022        _ => document.clone(),
5023    }
5024}
5025
5026fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
5027    documents.iter().map(collapse_structured_document).collect()
5028}
5029
5030fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
5031    let version = documents.iter().find(|document| {
5032        document
5033            .get("ManifestType")
5034            .and_then(JsonValue::as_str)
5035            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
5036    });
5037    let default_locale = documents.iter().find(|document| {
5038        document
5039            .get("ManifestType")
5040            .and_then(JsonValue::as_str)
5041            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
5042    });
5043    let installer = documents.iter().find(|document| {
5044        document
5045            .get("ManifestType")
5046            .and_then(JsonValue::as_str)
5047            .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
5048    });
5049
5050    let mut singleton = JsonMap::new();
5051    copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
5052    copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
5053    copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
5054
5055    if !singleton.contains_key("PackageLocale")
5056        && let Some(package_locale) = default_locale
5057            .and_then(|document| json_string(document, "PackageLocale"))
5058            .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
5059    {
5060        singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
5061    }
5062
5063    singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
5064    singleton.insert(
5065        "ManifestVersion".to_owned(),
5066        JsonValue::String(
5067            installer
5068                .and_then(|document| json_string(document, "ManifestVersion"))
5069                .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
5070                .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
5071                .unwrap_or_else(|| "1.10.0".to_owned()),
5072        ),
5073    );
5074    singleton.remove("DefaultLocale");
5075
5076    JsonValue::Object(singleton)
5077}
5078
5079fn is_merged_manifest_document(document: &JsonValue) -> bool {
5080    document
5081        .get("ManifestType")
5082        .and_then(JsonValue::as_str)
5083        .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
5084}
5085
5086fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
5087    let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
5088    let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
5089    let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
5090    let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
5091
5092    let version_document = serde_json::json!({
5093        "PackageIdentifier": package_identifier,
5094        "PackageVersion": package_version,
5095        "DefaultLocale": package_locale,
5096        "ManifestType": "version",
5097        "ManifestVersion": manifest_version,
5098    });
5099
5100    let mut default_locale_document = project_manifest_document(
5101        merged,
5102        &[
5103            "PackageIdentifier",
5104            "PackageVersion",
5105            "PackageLocale",
5106            "Publisher",
5107            "PublisherUrl",
5108            "PublisherSupportUrl",
5109            "PrivacyUrl",
5110            "Author",
5111            "PackageName",
5112            "PackageUrl",
5113            "License",
5114            "LicenseUrl",
5115            "Copyright",
5116            "CopyrightUrl",
5117            "ShortDescription",
5118            "Description",
5119            "Moniker",
5120            "Tags",
5121            "Agreements",
5122            "ReleaseNotes",
5123            "ReleaseNotesUrl",
5124            "PurchaseUrl",
5125            "InstallationNotes",
5126            "Documentations",
5127            "Icons",
5128        ],
5129    );
5130    default_locale_document.insert(
5131        "PackageIdentifier".to_owned(),
5132        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
5133    );
5134    default_locale_document.insert(
5135        "PackageVersion".to_owned(),
5136        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
5137    );
5138    default_locale_document.insert(
5139        "PackageLocale".to_owned(),
5140        JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
5141    );
5142    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
5143    default_locale_document.insert(
5144        "ManifestVersion".to_owned(),
5145        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
5146    );
5147
5148    let mut installer_document = project_manifest_document(
5149        merged,
5150        &[
5151            "PackageIdentifier",
5152            "PackageVersion",
5153            "Channel",
5154            "InstallerLocale",
5155            "Platform",
5156            "MinimumOSVersion",
5157            "InstallerType",
5158            "NestedInstallerType",
5159            "NestedInstallerFiles",
5160            "Scope",
5161            "InstallModes",
5162            "InstallerSwitches",
5163            "InstallerSuccessCodes",
5164            "ExpectedReturnCodes",
5165            "UpgradeBehavior",
5166            "Commands",
5167            "Protocols",
5168            "FileExtensions",
5169            "Dependencies",
5170            "PackageFamilyName",
5171            "ProductCode",
5172            "Capabilities",
5173            "RestrictedCapabilities",
5174            "Markets",
5175            "InstallerAbortsTerminal",
5176            "ReleaseDate",
5177            "InstallLocationRequired",
5178            "RequireExplicitUpgrade",
5179            "DisplayInstallWarnings",
5180            "UnsupportedOSArchitectures",
5181            "UnsupportedArguments",
5182            "AppsAndFeaturesEntries",
5183            "ElevationRequirement",
5184            "InstallationMetadata",
5185            "DownloadCommandProhibited",
5186            "RepairBehavior",
5187            "ArchiveBinariesDependOnPath",
5188            "Authentication",
5189            "Installers",
5190        ],
5191    );
5192    installer_document.insert(
5193        "PackageIdentifier".to_owned(),
5194        JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
5195    );
5196    installer_document.insert(
5197        "PackageVersion".to_owned(),
5198        JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
5199    );
5200    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
5201    installer_document.insert(
5202        "ManifestVersion".to_owned(),
5203        JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
5204    );
5205
5206    vec![
5207        version_document,
5208        JsonValue::Object(default_locale_document),
5209        JsonValue::Object(installer_document),
5210    ]
5211}
5212
5213fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
5214    let mut result = JsonMap::new();
5215    if let Some(source) = source.as_object() {
5216        for key in keys {
5217            if let Some(value) = source.get(*key) {
5218                result.insert((*key).to_owned(), value.clone());
5219            }
5220        }
5221    }
5222
5223    result
5224}
5225
5226fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
5227    if let Some(source) = source.and_then(JsonValue::as_object) {
5228        for key in keys {
5229            if let Some(value) = source.get(*key) {
5230                target.insert((*key).to_owned(), value.clone());
5231            }
5232        }
5233    }
5234}
5235
5236fn copy_all_manifest_keys_except(
5237    target: &mut JsonMap<String, JsonValue>,
5238    source: Option<&JsonValue>,
5239    excluded_keys: &[&str],
5240) {
5241    if let Some(source) = source.and_then(JsonValue::as_object) {
5242        for (key, value) in source {
5243            if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
5244                target.insert(key.clone(), value.clone());
5245            }
5246        }
5247    }
5248}
5249
5250fn build_rest_manifest_documents(
5251    data: &JsonValue,
5252    installer_source: &JsonValue,
5253    default_locale: &JsonValue,
5254    package_id: &str,
5255    version: &str,
5256    channel: &str,
5257) -> JsonValue {
5258    let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
5259    let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
5260    let manifest_version = json_string(installer_source, "ManifestVersion")
5261        .or_else(|| json_string(default_locale, "ManifestVersion"))
5262        .or_else(|| json_string(data, "ManifestVersion"))
5263        .unwrap_or_else(|| "1.10.0".to_owned());
5264
5265    let mut version_document = JsonMap::new();
5266    version_document.insert(
5267        "PackageIdentifier".to_owned(),
5268        JsonValue::String(package_identifier.clone()),
5269    );
5270    version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
5271    version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
5272    version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
5273    version_document.insert(
5274        "ManifestVersion".to_owned(),
5275        JsonValue::String(manifest_version.clone()),
5276    );
5277
5278    let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
5279    default_locale_document.insert(
5280        "PackageIdentifier".to_owned(),
5281        JsonValue::String(package_identifier.clone()),
5282    );
5283    default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
5284    default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
5285    default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
5286    default_locale_document.insert(
5287        "ManifestVersion".to_owned(),
5288        JsonValue::String(manifest_version.clone()),
5289    );
5290
5291    let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
5292    installer_document.remove("DefaultLocale");
5293    installer_document.remove("Locales");
5294    installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
5295    installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
5296    if !channel.is_empty() {
5297        installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
5298    }
5299    installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
5300    installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
5301
5302    let documents = vec![
5303        JsonValue::Object(version_document),
5304        JsonValue::Object(default_locale_document),
5305        JsonValue::Object(installer_document),
5306    ];
5307
5308    merge_manifest_documents(&documents)
5309}
5310
5311fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
5312    let base = installer_defaults(root);
5313    let base_switches = yaml_installer_switches(root);
5314    if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
5315        let installers = items
5316            .iter()
5317            .filter_map(YamlValue::as_mapping)
5318            .map(|item| {
5319                let mut merged = base.clone();
5320                merge_yaml_mapping(&mut merged, item);
5321                let switches = yaml_installer_switches(item).with_fallback(&base_switches);
5322                installer_from_yaml(&merged, switches)
5323            })
5324            .collect::<Vec<_>>();
5325        if !installers.is_empty() {
5326            return installers;
5327        }
5328    }
5329
5330    if base.is_empty() {
5331        Vec::new()
5332    } else {
5333        vec![installer_from_yaml(&base, base_switches)]
5334    }
5335}
5336
5337fn installer_defaults(root: &YamlMapping) -> YamlMapping {
5338    let keys = [
5339        "Architecture",
5340        "InstallerType",
5341        "InstallerUrl",
5342        "InstallerSha256",
5343        "ProductCode",
5344        "InstallerLocale",
5345        "Platform",
5346        "MinimumOSVersion",
5347        "Scope",
5348        "ReleaseDate",
5349        "PackageFamilyName",
5350        "UpgradeCode",
5351        "Commands",
5352    ];
5353    let mut defaults = YamlMapping::new();
5354    for key in keys {
5355        if let Some(value) = root.get(YamlValue::from(key)) {
5356            defaults.insert(YamlValue::from(key), value.clone());
5357        }
5358    }
5359    defaults
5360}
5361
5362fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
5363    Installer {
5364        architecture: yaml_string(root, "Architecture"),
5365        installer_type: yaml_string(root, "InstallerType"),
5366        url: yaml_string(root, "InstallerUrl"),
5367        sha256: yaml_string(root, "InstallerSha256"),
5368        product_code: yaml_string(root, "ProductCode"),
5369        locale: yaml_string(root, "InstallerLocale"),
5370        scope: yaml_string(root, "Scope"),
5371        release_date: yaml_string(root, "ReleaseDate"),
5372        package_family_name: yaml_string(root, "PackageFamilyName"),
5373        upgrade_code: yaml_string(root, "UpgradeCode"),
5374        platforms: yaml_string_list(root, "Platform"),
5375        minimum_os_version: yaml_string(root, "MinimumOSVersion"),
5376        switches,
5377        commands: yaml_string_list(root, "Commands"),
5378        package_dependencies: yaml_package_dependencies(root),
5379    }
5380}
5381
5382fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
5383    let mapping = root
5384        .get(YamlValue::from("InstallerSwitches"))
5385        .or_else(|| root.get(YamlValue::from("Switches")))
5386        .and_then(YamlValue::as_mapping);
5387    yaml_installer_switches_from_mapping(mapping)
5388}
5389
5390fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
5391    let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
5392
5393    InstallerSwitches {
5394        silent: string("Silent"),
5395        silent_with_progress: string("SilentWithProgress"),
5396        interactive: string("Interactive"),
5397        custom: string("Custom"),
5398        log: string("Log"),
5399        install_location: string("InstallLocation"),
5400    }
5401}
5402
5403fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
5404    yaml_string(root, key).or_else(|| {
5405        root.get(YamlValue::from("DefaultLocale"))
5406            .and_then(YamlValue::as_mapping)
5407            .and_then(|mapping| yaml_string(mapping, key))
5408    })
5409}
5410
5411fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
5412    yaml_string(root, key)
5413}
5414
5415fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
5416    root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
5417}
5418
5419fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
5420    match value {
5421        YamlValue::Null => None,
5422        YamlValue::Bool(value) => Some(value.to_string()),
5423        YamlValue::Number(value) => Some(value.to_string()),
5424        YamlValue::String(value) => Some(value.clone()),
5425        YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
5426        YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
5427    }
5428}
5429
5430fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
5431    root.get(YamlValue::from(key))
5432        .and_then(YamlValue::as_sequence)
5433        .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
5434        .unwrap_or_default()
5435}
5436
5437fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
5438    root.get(YamlValue::from("Documentations"))
5439        .and_then(YamlValue::as_sequence)
5440        .map(|items| {
5441            items
5442                .iter()
5443                .filter_map(YamlValue::as_mapping)
5444                .filter_map(|item| {
5445                    let url = yaml_string(item, "DocumentUrl")?;
5446                    Some(Documentation {
5447                        label: yaml_string(item, "DocumentLabel"),
5448                        url,
5449                    })
5450                })
5451                .collect()
5452        })
5453        .unwrap_or_default()
5454}
5455
5456fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
5457    root.get(YamlValue::from("Agreements"))
5458        .and_then(YamlValue::as_sequence)
5459        .map(|items| {
5460            items
5461                .iter()
5462                .filter_map(YamlValue::as_mapping)
5463                .map(|item| PackageAgreement {
5464                    label: yaml_string(item, "AgreementLabel"),
5465                    text: yaml_string(item, "Agreement"),
5466                    url: yaml_string(item, "AgreementUrl"),
5467                })
5468                .collect()
5469        })
5470        .unwrap_or_default()
5471}
5472
5473fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
5474    root.get(YamlValue::from("Dependencies"))
5475        .and_then(YamlValue::as_mapping)
5476        .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
5477        .and_then(YamlValue::as_sequence)
5478        .map(|items| {
5479            items
5480                .iter()
5481                .filter_map(YamlValue::as_mapping)
5482                .filter_map(|item| yaml_string(item, "PackageIdentifier"))
5483                .collect()
5484        })
5485        .unwrap_or_default()
5486}
5487
5488fn json_string(value: &JsonValue, key: &str) -> Option<String> {
5489    value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
5490}
5491
5492fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
5493    value
5494        .get(key)
5495        .and_then(JsonValue::as_array)
5496        .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
5497        .unwrap_or_default()
5498}
5499
5500fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
5501    value
5502        .get("Documentations")
5503        .and_then(JsonValue::as_array)
5504        .map(|items| {
5505            items
5506                .iter()
5507                .filter_map(|item| {
5508                    let url = json_string(item, "DocumentUrl")?;
5509                    Some(Documentation {
5510                        label: json_string(item, "DocumentLabel"),
5511                        url,
5512                    })
5513                })
5514                .collect()
5515        })
5516        .unwrap_or_default()
5517}
5518
5519fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
5520    value
5521        .get("Agreements")
5522        .and_then(JsonValue::as_array)
5523        .map(|items| {
5524            items
5525                .iter()
5526                .map(|item| PackageAgreement {
5527                    label: json_string(item, "AgreementLabel"),
5528                    text: json_string(item, "Agreement"),
5529                    url: json_string(item, "AgreementUrl"),
5530                })
5531                .collect()
5532        })
5533        .unwrap_or_default()
5534}
5535
5536fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
5537    value
5538        .get("Dependencies")
5539        .and_then(JsonValue::as_object)
5540        .and_then(|deps| deps.get("PackageDependencies"))
5541        .and_then(JsonValue::as_array)
5542        .map(|items| {
5543            items
5544                .iter()
5545                .filter_map(|item| json_string(item, "PackageIdentifier"))
5546                .collect()
5547        })
5548        .unwrap_or_default()
5549}
5550
5551fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
5552    let switches = value
5553        .get("InstallerSwitches")
5554        .or_else(|| value.get("Switches"))
5555        .and_then(JsonValue::as_object);
5556
5557    let string = |key: &str| {
5558        switches
5559            .and_then(|switches| switches.get(key))
5560            .and_then(JsonValue::as_str)
5561            .map(str::to_owned)
5562    };
5563
5564    InstallerSwitches {
5565        silent: string("Silent"),
5566        silent_with_progress: string("SilentWithProgress"),
5567        interactive: string("Interactive"),
5568        custom: string("Custom"),
5569        log: string("Log"),
5570        install_location: string("InstallLocation"),
5571    }
5572}
5573
5574fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
5575    for (key, value) in source {
5576        target.insert(key.clone(), value.clone());
5577    }
5578}
5579
5580fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
5581    let reader = Cursor::new(bytes.to_vec());
5582    let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
5583    let mut file = archive
5584        .by_name(member_name)
5585        .with_context(|| format!("zip payload missing {member_name}"))?;
5586    let mut output = Vec::new();
5587    file.read_to_end(&mut output)
5588        .with_context(|| format!("failed to read {member_name} from zip payload"))?;
5589    Ok(output)
5590}
5591
5592fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
5593    if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
5594        return Ok(output);
5595    }
5596
5597    if !bytes.starts_with(b"CK")
5598        && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
5599    {
5600        return decompress_mszyml_payload(&bytes[offset + 2..]);
5601    }
5602
5603    decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
5604}
5605
5606fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
5607    let mut decoder = flate2::read::DeflateDecoder::new(payload);
5608    let mut output = String::new();
5609    decoder.read_to_string(&mut output)?;
5610    Ok(output)
5611}
5612
5613fn cache_control_max_age(response: &Response) -> u64 {
5614    response
5615        .headers()
5616        .get(reqwest::header::CACHE_CONTROL)
5617        .and_then(|value| value.to_str().ok())
5618        .and_then(|header| {
5619            header.split(',').find_map(|part| {
5620                let trimmed = part.trim();
5621                trimmed
5622                    .strip_prefix("max-age=")
5623                    .and_then(|value| value.parse::<u64>().ok())
5624            })
5625        })
5626        .unwrap_or(60)
5627}
5628
5629fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
5630    REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
5631        let (major, minor) = major_minor(candidate);
5632        server_versions.iter().any(|version| {
5633            let (server_major, server_minor) = major_minor(version);
5634            server_major == major && server_minor == minor
5635        })
5636    })
5637}
5638
5639fn major_minor(version: &str) -> (String, String) {
5640    let mut parts = version.split('.');
5641    (
5642        parts.next().unwrap_or_default().to_owned(),
5643        parts.next().unwrap_or_default().to_owned(),
5644    )
5645}
5646
5647fn default_market() -> String {
5648    std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
5649}
5650
5651fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
5652    let requested_locale = query.locale.as_deref();
5653    let requested_architecture = query.installer_architecture.as_deref();
5654    let requested_type = query.installer_type.as_deref();
5655    let requested_scope = query.install_scope.as_deref();
5656    let requested_platform = query.platform.as_deref();
5657    let requested_os_version = query.os_version.as_deref();
5658    let system_architecture = current_architecture();
5659    let system_platform = current_platform();
5660    let current_os_version = current_os_version();
5661
5662    installers
5663        .iter()
5664        .enumerate()
5665        .filter(|(_, installer)| {
5666            installer_matches_requested(
5667                installer,
5668                requested_type,
5669                requested_scope,
5670                requested_platform,
5671                requested_os_version,
5672                system_platform,
5673                current_os_version.as_deref(),
5674            )
5675        })
5676        .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
5677        .max_by_key(|(index, installer)| {
5678            (
5679                installer_rank(installer, requested_locale, requested_architecture, system_architecture),
5680                std::cmp::Reverse(*index),
5681            )
5682        })
5683        .map(|(_, installer)| installer)
5684        .cloned()
5685}
5686
5687fn installer_matches_requested(
5688    installer: &Installer,
5689    requested_type: Option<&str>,
5690    requested_scope: Option<&str>,
5691    requested_platform: Option<&str>,
5692    requested_os_version: Option<&str>,
5693    system_platform: Option<&str>,
5694    current_os_version: Option<&str>,
5695) -> bool {
5696    matches_optional_ci(installer.installer_type.as_deref(), requested_type)
5697        && matches_optional_ci(installer.scope.as_deref(), requested_scope)
5698        && installer_matches_platform(installer, requested_platform, system_platform)
5699        && installer_matches_os_version(installer, requested_os_version, current_os_version)
5700}
5701
5702fn installer_matches_platform(
5703    installer: &Installer,
5704    requested_platform: Option<&str>,
5705    system_platform: Option<&str>,
5706) -> bool {
5707    if installer.platforms.is_empty() {
5708        return true;
5709    }
5710
5711    let Some(effective_platform) = requested_platform.or(system_platform) else {
5712        return true;
5713    };
5714
5715    installer
5716        .platforms
5717        .iter()
5718        .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
5719}
5720
5721fn installer_matches_os_version(
5722    installer: &Installer,
5723    requested_os_version: Option<&str>,
5724    current_os_version: Option<&str>,
5725) -> bool {
5726    let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
5727        return true;
5728    };
5729
5730    let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
5731        return true;
5732    };
5733
5734    match (
5735        parse_dotted_version(minimum_os_version),
5736        parse_dotted_version(actual_os_version),
5737    ) {
5738        (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
5739        _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
5740    }
5741}
5742
5743fn installer_matches_architecture(
5744    installer: &Installer,
5745    requested_architecture: Option<&str>,
5746    system_architecture: &str,
5747) -> bool {
5748    let Some(architecture) = installer.architecture.as_deref() else {
5749        return true;
5750    };
5751
5752    let architecture = architecture.to_ascii_lowercase();
5753    if let Some(requested) = requested_architecture {
5754        return architecture.eq_ignore_ascii_case(requested);
5755    }
5756
5757    preferred_architectures(system_architecture)
5758        .iter()
5759        .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
5760}
5761
5762fn installer_rank(
5763    installer: &Installer,
5764    requested_locale: Option<&str>,
5765    requested_architecture: Option<&str>,
5766    system_architecture: &str,
5767) -> (i32, i32, i32) {
5768    let architecture_rank = architecture_rank(
5769        installer.architecture.as_deref(),
5770        requested_architecture.unwrap_or(system_architecture),
5771        requested_architecture.is_some(),
5772        system_architecture,
5773    );
5774    let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
5775    let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
5776    (architecture_rank, locale_rank, command_rank)
5777}
5778
5779fn architecture_rank(
5780    installer_architecture: Option<&str>,
5781    preferred_architecture: &str,
5782    strict: bool,
5783    system_architecture: &str,
5784) -> i32 {
5785    let Some(value) = installer_architecture else {
5786        return 0;
5787    };
5788    if value.eq_ignore_ascii_case(preferred_architecture) {
5789        return 5;
5790    }
5791    if value.eq_ignore_ascii_case("neutral") {
5792        return 4;
5793    }
5794    if strict {
5795        return -1;
5796    }
5797
5798    preferred_architectures(system_architecture)
5799        .iter()
5800        .rev()
5801        .position(|candidate| value.eq_ignore_ascii_case(candidate))
5802        .and_then(|index| i32::try_from(index).ok())
5803        .and_then(|index| index.checked_add(1))
5804        .unwrap_or(-1)
5805}
5806
5807fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
5808    match system_architecture {
5809        "arm64" => &["arm64", "neutral", "x64", "x86"],
5810        "x64" => &["x64", "neutral", "x86"],
5811        "x86" => &["x86", "neutral"],
5812        _ => &["neutral"],
5813    }
5814}
5815
5816fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
5817    match (installer_locale, requested_locale) {
5818        (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
5819        (Some(installer), Some(requested))
5820            if installer
5821                .split('-')
5822                .next()
5823                .zip(requested.split('-').next())
5824                .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
5825        {
5826            2
5827        }
5828        (None, Some(_)) => 1,
5829        (Some(_), None) => 1,
5830        (None, None) => 0,
5831        _ => -1,
5832    }
5833}
5834
5835fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
5836    match requested {
5837        Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
5838        None => true,
5839    }
5840}
5841
5842fn current_architecture() -> &'static str {
5843    match std::env::consts::ARCH {
5844        "x86_64" => "x64",
5845        "x86" => "x86",
5846        "aarch64" => "arm64",
5847        _ => "neutral",
5848    }
5849}
5850
5851fn current_platform() -> Option<&'static str> {
5852    if cfg!(windows) { Some("Windows.Desktop") } else { None }
5853}
5854
5855#[cfg(windows)]
5856fn current_os_version() -> Option<String> {
5857    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
5858    let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
5859    let build: String = key.get_value("CurrentBuildNumber").ok()?;
5860    let ubr: u32 = key.get_value("UBR").unwrap_or(0);
5861    let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
5862    let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
5863    Some(format!("{major}.{minor}.{build}.{ubr}"))
5864}
5865
5866#[cfg(not(windows))]
5867fn current_os_version() -> Option<String> {
5868    None
5869}
5870
5871fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
5872    let mut parsed = Vec::new();
5873    for component in version.split('.') {
5874        if component.is_empty() {
5875            continue;
5876        }
5877        parsed.push(component.parse().ok()?);
5878    }
5879
5880    if parsed.is_empty() { None } else { Some(parsed) }
5881}
5882
5883fn select_v1_version(
5884    versions: &[V1VersionRow],
5885    requested_version: Option<&str>,
5886    requested_channel: Option<&str>,
5887) -> Result<V1VersionRow> {
5888    let mut ordered = versions.to_vec();
5889    ordered.sort_by(|left, right| {
5890        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
5891    });
5892
5893    if let Some(version) = requested_version {
5894        let channel = requested_channel.unwrap_or_default();
5895        return ordered
5896            .iter()
5897            .find(|candidate| {
5898                candidate.version.eq_ignore_ascii_case(version)
5899                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
5900            })
5901            .cloned()
5902            .ok_or_else(|| anyhow!("requested version {version} was not found"));
5903    }
5904
5905    ordered
5906        .into_iter()
5907        .next()
5908        .ok_or_else(|| anyhow!("package had no versions"))
5909}
5910
5911fn select_v2_version(
5912    versions: &[PackageVersionDataEntry],
5913    requested_version: Option<&str>,
5914) -> Result<PackageVersionDataEntry> {
5915    let mut ordered = versions.to_vec();
5916    ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
5917
5918    if let Some(version) = requested_version {
5919        return ordered
5920            .iter()
5921            .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
5922            .cloned()
5923            .ok_or_else(|| anyhow!("requested version {version} was not found"));
5924    }
5925
5926    ordered
5927        .into_iter()
5928        .next()
5929        .ok_or_else(|| anyhow!("package had no V2 versions"))
5930}
5931
5932fn select_rest_version<'a>(
5933    versions: &'a [VersionKey],
5934    requested_version: Option<&str>,
5935    requested_channel: Option<&str>,
5936) -> Result<&'a VersionKey> {
5937    if let Some(version) = requested_version {
5938        let channel = requested_channel.unwrap_or_default();
5939        return versions
5940            .iter()
5941            .find(|candidate| {
5942                candidate.version.eq_ignore_ascii_case(version)
5943                    && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
5944            })
5945            .ok_or_else(|| anyhow!("requested version {version} was not found"));
5946    }
5947
5948    versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
5949}
5950
5951fn sort_versions_desc(versions: &mut [VersionKey]) {
5952    versions.sort_by(|left, right| {
5953        compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
5954    });
5955}
5956
5957fn compare_version_and_channel(
5958    left_version: &str,
5959    left_channel: &str,
5960    right_version: &str,
5961    right_channel: &str,
5962) -> Ordering {
5963    compare_version(left_version, right_version).then_with(|| {
5964        left_channel
5965            .to_ascii_lowercase()
5966            .cmp(&right_channel.to_ascii_lowercase())
5967    })
5968}
5969
5970fn compare_version(left: &str, right: &str) -> Ordering {
5971    let left_parts = tokenize_version(left);
5972    let right_parts = tokenize_version(right);
5973    let max_len = left_parts.len().max(right_parts.len());
5974
5975    for index in 0..max_len {
5976        let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
5977        let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
5978        let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
5979
5980        let ordering = if let Some((left_number, right_number)) = numeric {
5981            left_number.cmp(&right_number)
5982        } else {
5983            left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
5984        };
5985
5986        if ordering != Ordering::Equal {
5987            return ordering;
5988        }
5989    }
5990
5991    Ordering::Equal
5992}
5993
5994fn tokenize_version(value: &str) -> Vec<String> {
5995    let mut parts = Vec::new();
5996    let mut current = String::new();
5997    let mut current_is_digit = None;
5998
5999    for ch in value.chars() {
6000        if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
6001            if !current.is_empty() {
6002                parts.push(std::mem::take(&mut current));
6003            }
6004            current_is_digit = None;
6005            continue;
6006        }
6007
6008        let is_digit = ch.is_ascii_digit();
6009        match current_is_digit {
6010            Some(previous) if previous != is_digit => {
6011                parts.push(std::mem::take(&mut current));
6012                current.push(ch);
6013                current_is_digit = Some(is_digit);
6014            }
6015            Some(_) => current.push(ch),
6016            None => {
6017                current.push(ch);
6018                current_is_digit = Some(is_digit);
6019            }
6020        }
6021    }
6022
6023    if !current.is_empty() {
6024        parts.push(current);
6025    }
6026
6027    parts
6028}
6029
6030fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
6031    if !hash_matches(Some(expected_hash), bytes) {
6032        bail!("downloaded file hash did not match expected SHA256");
6033    }
6034    Ok(())
6035}
6036
6037fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
6038    let Some(expected_hash) = expected_hash else {
6039        return Ok(());
6040    };
6041    if ignore_security_hash {
6042        return Ok(());
6043    }
6044
6045    if !hash_matches(Some(expected_hash), bytes) {
6046        let actual = sha256_hex(bytes);
6047        bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
6048    }
6049    Ok(())
6050}
6051
6052fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
6053    expected_hash
6054        .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
6055        .unwrap_or(true)
6056}
6057
6058fn sha256_hex(bytes: &[u8]) -> String {
6059    let digest = Sha256::digest(bytes);
6060    let mut output = String::with_capacity(digest.len() * 2);
6061    for byte in digest {
6062        output.push_str(&format!("{byte:02X}"));
6063    }
6064    output
6065}
6066
6067fn pins_db_path(app_root: &Path) -> PathBuf {
6068    if uses_packaged_layout(app_root) {
6069        app_root.join("pinning.db")
6070    } else {
6071        app_root.join("pins.db")
6072    }
6073}
6074
6075fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
6076    let mut stmt = conn.prepare("PRAGMA table_info(pin)")?;
6077    let mut has_current_column = false;
6078    let mut has_legacy_column = false;
6079
6080    let column_names = stmt.query_map([], |row| row.get::<_, String>(1))?;
6081    for column_name in column_names {
6082        match column_name?.as_str() {
6083            "type" => has_current_column = true,
6084            "pin_type" => has_legacy_column = true,
6085            _ => {}
6086        }
6087    }
6088
6089    Ok(if has_current_column {
6090        Some("type")
6091    } else if has_legacy_column {
6092        Some("pin_type")
6093    } else {
6094        None
6095    })
6096}
6097
6098fn decode_pin_type(pin_type_int: i64) -> PinType {
6099    match pin_type_int {
6100        4 => PinType::Blocking,
6101        3 => PinType::Gating,
6102        _ => PinType::Pinning,
6103    }
6104}
6105
6106#[cfg(windows)]
6107fn dispatch_installer(
6108    installer_path: &Path,
6109    installer_type: &str,
6110    request: &InstallRequest,
6111    manifest: &Manifest,
6112    installer: &Installer,
6113) -> Result<i32> {
6114    use std::process::Command;
6115
6116    match installer_type {
6117        "msi" | "wix" => {
6118            let mut cmd = Command::new("msiexec");
6119            cmd.args(installer_command_arguments(
6120                installer_type,
6121                request,
6122                manifest,
6123                installer_path,
6124                installer,
6125            ));
6126            let status = cmd.status().context("failed to run msiexec")?;
6127            Ok(status.code().unwrap_or(-1))
6128        }
6129        "msix" | "appx" => {
6130            let mut cmd = Command::new("powershell");
6131            cmd.arg("-NoProfile")
6132                .arg("-Command")
6133                .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
6134            let status = cmd.status().context("failed to run Add-AppxPackage")?;
6135            Ok(status.code().unwrap_or(-1))
6136        }
6137        "zip" => {
6138            let target = dirs::data_local_dir()
6139                .unwrap_or_else(|| PathBuf::from("."))
6140                .join("Programs");
6141            fs::create_dir_all(&target)?;
6142            let file = fs::File::open(installer_path)?;
6143            let mut archive = ZipArchive::new(file)?;
6144            archive.extract(&target)?;
6145            Ok(0)
6146        }
6147        // exe, inno, nullsoft, burn, etc.
6148        _ => {
6149            let mut cmd = Command::new(installer_path);
6150            cmd.args(installer_command_arguments(
6151                installer_type,
6152                request,
6153                manifest,
6154                installer_path,
6155                installer,
6156            ));
6157            let status = cmd.status().context("failed to run installer")?;
6158            Ok(status.code().unwrap_or(-1))
6159        }
6160    }
6161}
6162
6163#[cfg(not(windows))]
6164fn dispatch_installer(
6165    _installer_path: &Path,
6166    _installer_type: &str,
6167    _request: &InstallRequest,
6168    _manifest: &Manifest,
6169    _installer: &Installer,
6170) -> Result<i32> {
6171    bail!("Installing packages is only supported on Windows")
6172}
6173
6174#[cfg(any(windows, test))]
6175fn installer_command_arguments(
6176    installer_type: &str,
6177    request: &InstallRequest,
6178    _manifest: &Manifest,
6179    installer_path: &Path,
6180    installer: &Installer,
6181) -> Vec<String> {
6182    if let Some(override_args) = request.override_args.as_deref() {
6183        return split_installer_switches(override_args);
6184    }
6185
6186    let mut args = Vec::new();
6187    match installer_type {
6188        "msi" | "wix" => {
6189            args.push("/i".to_owned());
6190            args.push(installer_path.display().to_string());
6191        }
6192        "msix" | "appx" | "zip" => return Vec::new(),
6193        _ => {}
6194    }
6195
6196    let configured = match request.mode {
6197        InstallerMode::Interactive => installer.switches.interactive.as_deref(),
6198        InstallerMode::SilentWithProgress => installer
6199            .switches
6200            .silent_with_progress
6201            .as_deref()
6202            .or(installer.switches.silent.as_deref()),
6203        InstallerMode::Silent => installer
6204            .switches
6205            .silent
6206            .as_deref()
6207            .or(installer.switches.silent_with_progress.as_deref()),
6208    };
6209
6210    if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
6211        args.extend(split_installer_switches(value));
6212    } else {
6213        args.extend(default_installer_arguments(installer_type, request.mode));
6214    }
6215
6216    append_switches(
6217        &mut args,
6218        resolve_template_switch(
6219            installer.switches.log.as_deref(),
6220            default_log_switch(installer_type),
6221            request.log_path.as_ref().map(|path| path.display().to_string()),
6222            "<LOGPATH>",
6223        ),
6224    );
6225    append_switches(&mut args, installer.switches.custom.clone());
6226    append_switches(&mut args, request.custom.clone());
6227    append_switches(
6228        &mut args,
6229        resolve_template_switch(
6230            installer.switches.install_location.as_deref(),
6231            default_install_location_switch(installer_type),
6232            request.install_location.clone(),
6233            "<INSTALLPATH>",
6234        ),
6235    );
6236    args
6237}
6238
6239#[cfg(any(windows, test))]
6240fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
6241    match mode {
6242        InstallerMode::Interactive => Vec::new(),
6243        InstallerMode::SilentWithProgress => match installer_type {
6244            "inno" => vec![
6245                "/SP-".to_owned(),
6246                "/SILENT".to_owned(),
6247                "/SUPPRESSMSGBOXES".to_owned(),
6248                "/NORESTART".to_owned(),
6249            ],
6250            "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
6251            "nullsoft" | "nsis" => vec!["/S".to_owned()],
6252            _ => vec!["/SILENT".to_owned()],
6253        },
6254        InstallerMode::Silent => match installer_type {
6255            "inno" => vec![
6256                "/SP-".to_owned(),
6257                "/VERYSILENT".to_owned(),
6258                "/SUPPRESSMSGBOXES".to_owned(),
6259                "/NORESTART".to_owned(),
6260            ],
6261            "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
6262            "nullsoft" | "nsis" => vec!["/S".to_owned()],
6263            _ => vec!["/S".to_owned()],
6264        },
6265    }
6266}
6267
6268#[cfg(any(windows, test))]
6269fn default_log_switch(installer_type: &str) -> Option<&'static str> {
6270    match installer_type {
6271        "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
6272        "inno" => Some("/LOG=\"<LOGPATH>\""),
6273        _ => None,
6274    }
6275}
6276
6277#[cfg(any(windows, test))]
6278fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
6279    match installer_type {
6280        "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
6281        "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
6282        "inno" => Some("/DIR=\"<INSTALLPATH>\""),
6283        _ => None,
6284    }
6285}
6286
6287#[cfg(any(windows, test))]
6288fn resolve_template_switch(
6289    manifest_value: Option<&str>,
6290    fallback: Option<&str>,
6291    replacement: Option<String>,
6292    token: &str,
6293) -> Option<String> {
6294    let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
6295    let replacement = replacement?;
6296    Some(template.replace(token, &replacement))
6297}
6298
6299#[cfg(any(windows, test))]
6300fn append_switches(args: &mut Vec<String>, value: Option<String>) {
6301    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
6302        args.extend(split_installer_switches(&value));
6303    }
6304}
6305
6306#[cfg(any(windows, test))]
6307fn split_installer_switches(value: &str) -> Vec<String> {
6308    let mut args = Vec::new();
6309    let mut current = String::new();
6310    let mut in_quotes = false;
6311
6312    for ch in value.chars() {
6313        match ch {
6314            '"' => in_quotes = !in_quotes,
6315            c if c.is_whitespace() && !in_quotes => {
6316                if !current.is_empty() {
6317                    args.push(std::mem::take(&mut current));
6318                }
6319            }
6320            _ => current.push(ch),
6321        }
6322    }
6323
6324    if !current.is_empty() {
6325        args.push(current);
6326    }
6327
6328    args
6329}
6330
6331#[cfg(windows)]
6332fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
6333    if request.purge && request.preserve {
6334        bail!("--purge and --preserve cannot be used together.");
6335    }
6336
6337    if installed
6338        .installer_category
6339        .as_deref()
6340        .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
6341    {
6342        return uninstall_portable(installed, request);
6343    }
6344
6345    if (request.purge || request.preserve) && !request.force {
6346        bail!("--purge and --preserve are currently only supported for portable packages.");
6347    }
6348
6349    if let Some(exit_code) = try_uninstall_arp(installed, request)? {
6350        return Ok(exit_code);
6351    }
6352
6353    if let Some(exit_code) = try_uninstall_msix(installed)? {
6354        return Ok(exit_code);
6355    }
6356
6357    bail!(
6358        "No uninstall command found for installed package '{}' ({})",
6359        installed.name,
6360        installed.local_id
6361    );
6362}
6363
6364#[cfg(windows)]
6365fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
6366    use std::os::windows::process::CommandExt;
6367    use std::process::Command;
6368
6369    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
6370    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
6371
6372    let arp_paths = [
6373        r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
6374        r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
6375    ];
6376
6377    for root in [&hklm, &hkcu] {
6378        for arp_path in &arp_paths {
6379            if let Ok(key) = root.open_subkey(arp_path) {
6380                for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
6381                    if let Ok(subkey) = key.open_subkey(&subkey_name) {
6382                        let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
6383                        let product_code = subkey.get_value::<String, _>("ProductCode").ok();
6384                        if !registry_entry_matches_installed_package(
6385                            &subkey_name,
6386                            &display_name,
6387                            product_code.as_deref(),
6388                            installed,
6389                        ) {
6390                            continue;
6391                        }
6392
6393                        if let Some(exit_code) =
6394                            try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
6395                        {
6396                            return Ok(Some(exit_code));
6397                        }
6398
6399                        let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
6400                        let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
6401                            subkey.get_value("UninstallString").ok()
6402                        } else {
6403                            quiet_uninstall_cmd
6404                                .clone()
6405                                .or_else(|| subkey.get_value("UninstallString").ok())
6406                        }
6407                        .context("No uninstall command found in registry")?;
6408
6409                        if is_winget_uninstall_command(&uninstall_cmd)
6410                            && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
6411                        {
6412                            return Ok(Some(exit_code));
6413                        }
6414
6415                        let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
6416                        let uninstall_line = build_uninstall_command_with_mode(
6417                            &uninstall_cmd,
6418                            request.mode,
6419                            quiet_uninstall_cmd.is_some(),
6420                            log_path.as_deref(),
6421                        );
6422                        // `UninstallString` is a complete command line and may already contain
6423                        // its own quotes (e.g. `"C:\path with spaces\unins.exe"`). Passing it
6424                        // through Command::arg causes Rust's CRT-style quoting to backslash-escape
6425                        // those embedded quotes (`\"...\"`), which cmd.exe does not understand and
6426                        // rejects with "is not recognized as an internal or external command".
6427                        // Use `cmd /S /C "<line>"`: `/S` makes cmd strip only the outermost pair
6428                        // of quotes, leaving the embedded quotes intact. `raw_arg` bypasses Rust's
6429                        // automatic quoting so we can feed cmd the literal command line.
6430                        let mut cmd = Command::new("cmd");
6431                        cmd.raw_arg("/S /C");
6432                        cmd.raw_arg(format!("\"{uninstall_line}\""));
6433                        let status = cmd.status().context("failed to run uninstaller")?;
6434                        return Ok(Some(status.code().unwrap_or(-1)));
6435                    }
6436                }
6437            }
6438        }
6439    }
6440
6441    Ok(None)
6442}
6443
6444#[cfg(windows)]
6445fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
6446    use std::process::Command;
6447
6448    if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
6449        return Ok(None);
6450    }
6451
6452    let package_full_name = installed.local_id.strip_prefix("MSIX\\");
6453    let mut cmd = Command::new("powershell");
6454    cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
6455        package_full_name,
6456        &installed.package_family_names,
6457    ));
6458    let status = cmd.status().context("failed to run Remove-AppxPackage")?;
6459    Ok(Some(status.code().unwrap_or(-1)))
6460}
6461
6462#[cfg(windows)]
6463fn arp_subkey_name(local_id: &str) -> Option<&str> {
6464    local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
6465}
6466
6467#[cfg(windows)]
6468fn registry_entry_matches_installed_package(
6469    subkey_name: &str,
6470    display_name: &str,
6471    product_code: Option<&str>,
6472    installed: &ListMatch,
6473) -> bool {
6474    if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
6475        && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
6476    {
6477        return true;
6478    }
6479
6480    if installed.product_codes.iter().any(|code| {
6481        code.eq_ignore_ascii_case(subkey_name)
6482            || product_code
6483                .map(|value| code.eq_ignore_ascii_case(value))
6484                .unwrap_or(false)
6485    }) {
6486        return true;
6487    }
6488
6489    display_name.eq_ignore_ascii_case(&installed.name)
6490}
6491
6492#[cfg(all(windows, test))]
6493fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
6494    build_uninstall_command_with_mode(
6495        uninstall_cmd,
6496        if silent {
6497            InstallerMode::Silent
6498        } else {
6499            InstallerMode::Interactive
6500        },
6501        has_quiet_command,
6502        None,
6503    )
6504}
6505
6506#[cfg(windows)]
6507fn build_uninstall_command_with_mode(
6508    uninstall_cmd: &str,
6509    mode: InstallerMode,
6510    has_quiet_command: bool,
6511    log_path: Option<&str>,
6512) -> String {
6513    let uninstall_cmd = log_path
6514        .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
6515        .unwrap_or_else(|| uninstall_cmd.to_owned());
6516    if mode == InstallerMode::Interactive || has_quiet_command {
6517        return uninstall_cmd;
6518    }
6519
6520    let lower = uninstall_cmd.to_ascii_lowercase();
6521    if is_winget_uninstall_command_lower(&lower) {
6522        return uninstall_cmd;
6523    }
6524    if lower.contains("/quiet")
6525        || lower.contains("/passive")
6526        || lower.contains("/verysilent")
6527        || lower.contains("/silent")
6528        || lower.contains(" /s")
6529    {
6530        return uninstall_cmd;
6531    }
6532
6533    format!("{uninstall_cmd} /S")
6534}
6535
6536#[cfg(windows)]
6537fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
6538    let Some(args) = build_winget_uninstall_arguments(installed, request) else {
6539        return Ok(None);
6540    };
6541
6542    let exit_code = run_winget_uninstall(&args)?;
6543    if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
6544        let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
6545            return Ok(Some(exit_code));
6546        };
6547
6548        return Ok(Some(run_winget_uninstall(&args_without_scope)?));
6549    }
6550
6551    Ok(Some(exit_code))
6552}
6553
6554#[cfg(windows)]
6555fn run_winget_uninstall(args: &[String]) -> Result<i32> {
6556    use std::process::Command;
6557
6558    let status = Command::new("winget")
6559        .args(args)
6560        .status()
6561        .context("failed to run delegated winget uninstall")?;
6562    Ok(status.code().unwrap_or(-1))
6563}
6564
6565#[cfg(windows)]
6566fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
6567    build_winget_uninstall_arguments_with_scope(installed, request, true)
6568}
6569
6570#[cfg(windows)]
6571fn build_winget_uninstall_arguments_with_scope(
6572    installed: &ListMatch,
6573    request: &UninstallRequest,
6574    include_scope: bool,
6575) -> Option<Vec<String>> {
6576    if installed.id.eq_ignore_ascii_case(&installed.local_id) {
6577        return None;
6578    }
6579
6580    let mut args = vec![
6581        "uninstall".to_owned(),
6582        "--id".to_owned(),
6583        installed.id.clone(),
6584        "--exact".to_owned(),
6585        "--disable-interactivity".to_owned(),
6586    ];
6587
6588    if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
6589        args.push("--source".to_owned());
6590        args.push(source_name.to_owned());
6591    }
6592
6593    if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
6594        args.push("--scope".to_owned());
6595        args.push(scope.to_owned());
6596    }
6597
6598    if request.mode == InstallerMode::Silent {
6599        args.push("--silent".to_owned());
6600    }
6601
6602    Some(args)
6603}
6604
6605#[cfg(windows)]
6606fn is_winget_uninstall_command(command: &str) -> bool {
6607    is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
6608}
6609
6610#[cfg(windows)]
6611fn is_winget_uninstall_command_lower(command: &str) -> bool {
6612    command.contains("winget uninstall") || command.contains("winget.exe uninstall")
6613}
6614
6615#[cfg(windows)]
6616fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
6617    let full_name_literal = package_full_name
6618        .map(|value| format!("'{}'", value.replace('\'', "''")))
6619        .unwrap_or_else(|| "$null".to_owned());
6620    let family_names_literal = if package_family_names.is_empty() {
6621        "@()".to_owned()
6622    } else {
6623        format!(
6624            "@({})",
6625            package_family_names
6626                .iter()
6627                .map(|value| format!("'{}'", value.replace('\'', "''")))
6628                .collect::<Vec<_>>()
6629                .join(", ")
6630        )
6631    };
6632
6633    format!(
6634        "$fullName = {full_name_literal}; \
6635         $familyNames = {family_names_literal}; \
6636         $targets = Get-AppxPackage | Where-Object {{ \
6637         (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
6638         ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
6639         if (-not $targets) {{ exit 1 }}; \
6640         $targets | Remove-AppxPackage"
6641    )
6642}
6643
6644#[cfg(windows)]
6645fn try_run_msi_uninstall(
6646    installed: &ListMatch,
6647    subkey_name: &str,
6648    product_code: Option<&str>,
6649    request: &UninstallRequest,
6650) -> Result<Option<i32>> {
6651    use std::process::Command;
6652
6653    let uninstall_code = installed
6654        .product_codes
6655        .iter()
6656        .map(String::as_str)
6657        .chain(product_code)
6658        .chain(Some(subkey_name))
6659        .find(|value| is_product_code_like(value));
6660
6661    let Some(uninstall_code) = uninstall_code else {
6662        return Ok(None);
6663    };
6664
6665    let mut cmd = Command::new("msiexec");
6666    cmd.arg("/x").arg(uninstall_code);
6667    match request.mode {
6668        InstallerMode::Interactive => {}
6669        InstallerMode::SilentWithProgress => {
6670            cmd.arg("/passive").arg("/norestart");
6671        }
6672        InstallerMode::Silent => {
6673            cmd.arg("/quiet").arg("/norestart");
6674        }
6675    }
6676    if let Some(log_path) = &request.log_path {
6677        cmd.arg("/log").arg(log_path);
6678    }
6679
6680    let status = cmd.status().context("failed to run msiexec uninstall")?;
6681    Ok(Some(status.code().unwrap_or(-1)))
6682}
6683
6684#[cfg(windows)]
6685fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
6686    let Some(location) = installed.install_location.as_deref() else {
6687        if request.force {
6688            return Ok(0);
6689        }
6690        bail!(
6691            "Portable package '{}' does not expose an install location.",
6692            installed.name
6693        );
6694    };
6695
6696    if request.preserve {
6697        return Ok(0);
6698    }
6699
6700    let path = Path::new(location);
6701    if path.is_dir() {
6702        fs::remove_dir_all(path)?;
6703        return Ok(0);
6704    }
6705    if path.is_file() {
6706        fs::remove_file(path)?;
6707        return Ok(0);
6708    }
6709    if request.force {
6710        return Ok(0);
6711    }
6712
6713    bail!("Portable package location not found: {location}")
6714}
6715
6716#[cfg(windows)]
6717fn is_product_code_like(value: &str) -> bool {
6718    value.starts_with('{') && value.ends_with('}')
6719}
6720
6721#[cfg(not(windows))]
6722fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
6723    bail!("Uninstalling packages is only supported on Windows")
6724}
6725
6726#[cfg(test)]
6727mod tests {
6728    use std::io::Write;
6729    use std::path::{Path, PathBuf};
6730
6731    use flate2::Compression;
6732    use flate2::write::DeflateEncoder;
6733
6734    use super::*;
6735
6736    fn temp_app_root(label: &str) -> PathBuf {
6737        std::env::temp_dir().join(format!(
6738            "pinget-rs-tests-{label}-{}",
6739            Utc::now().timestamp_nanos_opt().unwrap_or_default()
6740        ))
6741    }
6742
6743    #[test]
6744    fn repository_options_capture_custom_host_settings() {
6745        let options =
6746            RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test")).with_user_agent("pinget-rs-tests/1.0");
6747
6748        assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
6749        assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
6750    }
6751
6752    #[test]
6753    fn dependency_install_requests_are_silent_and_not_forced() {
6754        let mut parent = InstallRequest::new(PackageQuery {
6755            source: Some("winget".to_owned()),
6756            ..PackageQuery::default()
6757        });
6758        parent.mode = InstallerMode::SilentWithProgress;
6759        parent.force = true;
6760        parent.accept_package_agreements = true;
6761        parent.ignore_security_hash = true;
6762        parent.dependency_source = Some("dependencies".to_owned());
6763
6764        let dependency =
6765            Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
6766
6767        assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
6768        assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
6769        assert_eq!(dependency.mode, InstallerMode::Silent);
6770        assert!(!dependency.force);
6771        assert!(dependency.no_upgrade);
6772        assert!(dependency.accept_package_agreements);
6773        assert!(dependency.ignore_security_hash);
6774    }
6775
6776    #[test]
6777    fn storage_paths_use_configured_app_root() {
6778        let app_root = PathBuf::from(r"C:\temp\pinget-test");
6779        let source = SourceRecord {
6780            name: "winget/test".to_owned(),
6781            kind: SourceKind::PreIndexed,
6782            arg: "https://example.com/cache".to_owned(),
6783            identifier: "Test.Source".to_owned(),
6784            trust_level: "None".to_owned(),
6785            explicit: false,
6786            priority: 0,
6787            last_update: None,
6788            source_version: None,
6789        };
6790
6791        assert_eq!(store_path(&app_root), app_root.join("sources.json"));
6792        assert_eq!(
6793            source_state_dir(&app_root, &source),
6794            app_root.join("sources").join("winget_test")
6795        );
6796        assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
6797    }
6798
6799    #[cfg(windows)]
6800    #[test]
6801    fn packaged_layout_paths_resolve_from_packaged_app_root() {
6802        let local_app_data = dirs::data_local_dir().expect("LocalAppData");
6803        let app_root = local_app_data
6804            .join("Packages")
6805            .join(PACKAGED_FAMILY_NAME)
6806            .join("LocalState");
6807        assert!(uses_packaged_layout(&app_root));
6808        assert!(
6809            user_settings_path(&app_root)
6810                .display()
6811                .to_string()
6812                .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
6813        );
6814        assert!(
6815            packaged_file_cache_root(&app_root)
6816                .display()
6817                .to_string()
6818                .ends_with(&format!(
6819                    r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
6820                    PACKAGED_FAMILY_NAME
6821                ))
6822        );
6823    }
6824
6825    #[cfg(windows)]
6826    #[test]
6827    fn default_app_root_outside_package_avoids_packaged_layout() {
6828        // Tests run without AppX package identity, so the default app root must not
6829        // resolve to the WinGet packaged LocalState — that location requires a
6830        // brokered/elevated writer for its secure-settings stream.
6831        let prior = std::env::var_os("PINGET_APPROOT");
6832        // SAFETY: tests in this module are not run concurrently with other env mutators.
6833        unsafe { std::env::remove_var("PINGET_APPROOT") };
6834
6835        let result = default_app_root();
6836
6837        if let Some(prior) = prior {
6838            // SAFETY: restoring the prior value before assertions panic, single-threaded test.
6839            unsafe { std::env::set_var("PINGET_APPROOT", prior) };
6840        }
6841
6842        let app_root = result.expect("default app root");
6843        assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
6844        assert!(
6845            app_root.ends_with(Path::new("Devolutions").join("Pinget")),
6846            "unexpected fallback app root: {app_root:?}"
6847        );
6848    }
6849
6850    #[test]
6851    fn packaged_source_stream_overlays_defaults_and_metadata() {
6852        let store = parse_packaged_source_store(
6853            Some(
6854                "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",
6855            ),
6856            Some(
6857                "Sources:\n  - Name: corp\n    LastUpdate: 1700000000\n    SourceVersion: 1.2.3\n",
6858            ),
6859        )
6860        .expect("packaged store");
6861
6862        assert!(!store.sources.iter().any(|source| source.name == "winget"));
6863        let corp = store
6864            .sources
6865            .iter()
6866            .find(|source| source.name == "corp")
6867            .expect("corp source");
6868        assert_eq!(corp.kind, SourceKind::Rest);
6869        assert_eq!(corp.identifier, "Contoso.Rest");
6870        assert_eq!(corp.trust_level, "Trusted");
6871        assert!(corp.explicit);
6872        assert_eq!(corp.priority, 7);
6873        assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
6874        assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
6875    }
6876
6877    #[test]
6878    fn system_winget_export_parses_json_lines() {
6879        let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
6880{"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"}
6881"#;
6882
6883        let sources = parse_system_winget_source_export(output).expect("source export");
6884
6885        assert_eq!(
6886            sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
6887            vec!["contoso", "contoso-cache"]
6888        );
6889        assert_eq!(sources[0].kind, SourceKind::Rest);
6890        assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
6891        assert_eq!(sources[0].identifier, "api.contoso.test");
6892        assert_eq!(sources[0].trust_level, "Trusted");
6893        assert!(!sources[0].explicit);
6894        assert_eq!(sources[1].kind, SourceKind::PreIndexed);
6895        assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
6896        assert!(sources[1].explicit);
6897    }
6898
6899    #[test]
6900    fn system_winget_export_parses_wrapped_sources() {
6901        let output = r#"{
6902  "Sources": [
6903    {
6904      "Name": "corp",
6905      "Type": "Microsoft.Rest",
6906      "Arg": "https://packages.contoso.test/api",
6907      "Identifier": "Contoso.Rest",
6908      "TrustLevel": "Trusted",
6909      "Explicit": true,
6910      "Priority": 9
6911    }
6912  ]
6913}"#;
6914
6915        let sources = parse_system_winget_source_export(output).expect("source export");
6916        let source = sources.first().expect("source");
6917        assert_eq!(sources.len(), 1);
6918        assert_eq!(source.name, "corp");
6919        assert_eq!(source.kind, SourceKind::Rest);
6920        assert_eq!(source.identifier, "Contoso.Rest");
6921        assert_eq!(source.trust_level, "Trusted");
6922        assert!(source.explicit);
6923        assert_eq!(source.priority, 9);
6924    }
6925
6926    #[test]
6927    fn system_winget_source_store_detects_secure_settings_stub() {
6928        assert!(is_secure_settings_stub(Some(
6929            "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
6930        )));
6931        assert!(!is_secure_settings_stub(Some("Sources:\n  - Name: winget\n")));
6932    }
6933
6934    #[test]
6935    fn system_winget_source_store_builds_add_arguments() {
6936        let args = build_winget_source_add_arguments(
6937            "corp",
6938            "https://packages.contoso.test/api",
6939            SourceKind::Rest,
6940            "Trusted",
6941            true,
6942        );
6943
6944        assert_eq!(
6945            args,
6946            vec![
6947                "source",
6948                "add",
6949                "--name",
6950                "corp",
6951                "--arg",
6952                "https://packages.contoso.test/api",
6953                "--type",
6954                "Microsoft.Rest",
6955                "--disable-interactivity",
6956                "--trust-level",
6957                "trusted",
6958                "--explicit",
6959            ]
6960        );
6961    }
6962
6963    fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
6964        Ok(WingetSourceCommandResult {
6965            exit_code: 0,
6966            stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
6967"#
6968            .to_owned(),
6969            stderr: String::new(),
6970        })
6971    }
6972
6973    #[test]
6974    fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
6975        let original_runner = {
6976            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
6977            let original = *runner;
6978            *runner = fake_system_winget_source_export;
6979            original
6980        };
6981
6982        let result = load_packaged_store_from_streams(
6983            Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
6984            None,
6985        );
6986
6987        {
6988            let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
6989            *runner = original_runner;
6990        }
6991
6992        let store = result.expect("packaged store").expect("packaged store");
6993        let source = store.sources.first().expect("source");
6994        assert_eq!(store.sources.len(), 1);
6995        assert_eq!(source.name, "contoso");
6996        assert_eq!(source.kind, SourceKind::Rest);
6997    }
6998
6999    #[test]
7000    fn source_metadata_and_named_reset_round_trip() {
7001        let app_root = temp_app_root("source-reset");
7002        let result = (|| -> Result<()> {
7003            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
7004            repository.add_source_with_metadata(
7005                "test",
7006                "https://example.com/test",
7007                SourceKind::Rest,
7008                Some("trusted"),
7009                true,
7010                4,
7011            )?;
7012
7013            repository.edit_source("test", Some(false), None)?;
7014            let edited = repository
7015                .list_sources()
7016                .into_iter()
7017                .find(|source| source.name == "test")
7018                .expect("source");
7019            assert_eq!(edited.trust_level, "Trusted");
7020            assert!(!edited.explicit);
7021            assert_eq!(edited.priority, 4);
7022
7023            repository.reset_source("test")?;
7024            let reset = repository
7025                .list_sources()
7026                .into_iter()
7027                .find(|source| source.name == "test")
7028                .expect("source");
7029            assert_eq!(reset.trust_level, "Trusted");
7030            assert!(!reset.explicit);
7031            assert_eq!(reset.priority, 4);
7032            assert!(reset.last_update.is_none());
7033            assert!(reset.source_version.is_none());
7034            Ok(())
7035        })();
7036
7037        let _ = fs::remove_dir_all(&app_root);
7038        result.expect("source metadata");
7039    }
7040
7041    #[test]
7042    fn explicit_sources_are_skipped_by_default_search_resolution() {
7043        let app_root = temp_app_root("source-resolution");
7044        let result = (|| -> Result<()> {
7045            let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
7046            repository.add_source_with_metadata(
7047                "explicit-test",
7048                "https://example.com/explicit",
7049                SourceKind::Rest,
7050                Some("trusted"),
7051                true,
7052                10,
7053            )?;
7054
7055            let indexes = repository.resolve_source_indexes(None)?;
7056            let names: Vec<_> = indexes
7057                .into_iter()
7058                .map(|index| repository.list_sources()[index].name.clone())
7059                .collect();
7060            assert!(!names.iter().any(|name| name == "explicit-test"));
7061            Ok(())
7062        })();
7063
7064        let _ = fs::remove_dir_all(&app_root);
7065        result.expect("source resolution");
7066    }
7067
7068    #[test]
7069    fn user_and_admin_settings_round_trip() {
7070        let app_root = temp_app_root("settings");
7071        let result = (|| -> Result<()> {
7072            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
7073            repository.set_user_settings(
7074                &serde_json::json!({
7075                    "visual": {
7076                        "progressBar": "retro"
7077                    }
7078                }),
7079                false,
7080            )?;
7081            repository.set_user_settings(
7082                &serde_json::json!({
7083                    "experimentalFeatures": {
7084                        "directMSI": true
7085                    }
7086                }),
7087                true,
7088            )?;
7089
7090            let user_settings = repository.get_user_settings()?;
7091            assert_eq!(
7092                user_settings["visual"]["progressBar"],
7093                serde_json::Value::String("retro".to_owned())
7094            );
7095            assert_eq!(
7096                user_settings["experimentalFeatures"]["directMSI"],
7097                serde_json::Value::Bool(true)
7098            );
7099            assert!(repository.test_user_settings(
7100                &serde_json::json!({
7101                    "experimentalFeatures": {
7102                        "directMSI": true
7103                    }
7104                }),
7105                true
7106            )?);
7107
7108            repository.set_admin_setting("LocalManifestFiles", true)?;
7109            repository.set_admin_setting("InstallerHashOverride", true)?;
7110            let admin_settings = repository.get_admin_settings()?;
7111            assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
7112            assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
7113
7114            repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
7115            let reset_one = repository.get_admin_settings()?;
7116            assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
7117
7118            repository.reset_admin_setting(None, true)?;
7119            let reset_all = repository.get_admin_settings()?;
7120            for name in Repository::supported_admin_settings() {
7121                assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
7122            }
7123            Ok(())
7124        })();
7125
7126        let _ = fs::remove_dir_all(&app_root);
7127        result.expect("settings");
7128    }
7129
7130    #[test]
7131    fn show_result_structured_document_is_manifest_oriented() {
7132        let result = ShowResult {
7133            package: SearchMatch {
7134                source_name: "winget".to_owned(),
7135                source_kind: SourceKind::PreIndexed,
7136                id: "Test.Package".to_owned(),
7137                name: "Test Package".to_owned(),
7138                moniker: Some("testpkg".to_owned()),
7139                version: Some("1.2.3".to_owned()),
7140                channel: Some("stable".to_owned()),
7141                match_criteria: Some("Id".to_owned()),
7142            },
7143            manifest: Manifest {
7144                id: "Test.Package".to_owned(),
7145                name: "Test Package".to_owned(),
7146                version: "1.2.3".to_owned(),
7147                channel: "stable".to_owned(),
7148                publisher: Some("Contoso".to_owned()),
7149                description: Some("Structured output".to_owned()),
7150                moniker: Some("testpkg".to_owned()),
7151                package_url: None,
7152                publisher_url: None,
7153                publisher_support_url: None,
7154                license: None,
7155                license_url: None,
7156                privacy_url: None,
7157                author: None,
7158                copyright: None,
7159                copyright_url: None,
7160                release_notes: None,
7161                release_notes_url: None,
7162                tags: vec!["utility".to_owned()],
7163                agreements: Vec::new(),
7164                package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
7165                documentation: vec![Documentation {
7166                    label: Some("Docs".to_owned()),
7167                    url: "https://example.test/docs".to_owned(),
7168                }],
7169                installers: vec![Installer {
7170                    architecture: Some("x64".to_owned()),
7171                    installer_type: Some("msix".to_owned()),
7172                    url: Some("https://example.test/Test.Package.msix".to_owned()),
7173                    sha256: Some("ABC123".to_owned()),
7174                    product_code: None,
7175                    locale: Some("en-US".to_owned()),
7176                    scope: Some("machine".to_owned()),
7177                    release_date: None,
7178                    package_family_name: None,
7179                    upgrade_code: None,
7180                    platforms: Vec::new(),
7181                    minimum_os_version: None,
7182                    switches: InstallerSwitches {
7183                        silent: Some("/quiet".to_owned()),
7184                        ..InstallerSwitches::default()
7185                    },
7186                    commands: vec!["testpkg".to_owned()],
7187                    package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
7188                }],
7189            },
7190            selected_installer: Some(Installer {
7191                architecture: Some("x64".to_owned()),
7192                installer_type: Some("msix".to_owned()),
7193                url: Some("https://example.test/Test.Package.msix".to_owned()),
7194                sha256: Some("ABC123".to_owned()),
7195                product_code: None,
7196                locale: Some("en-US".to_owned()),
7197                scope: Some("machine".to_owned()),
7198                release_date: None,
7199                package_family_name: None,
7200                upgrade_code: None,
7201                platforms: Vec::new(),
7202                minimum_os_version: None,
7203                switches: InstallerSwitches {
7204                    silent: Some("/quiet".to_owned()),
7205                    ..InstallerSwitches::default()
7206                },
7207                commands: vec!["testpkg".to_owned()],
7208                package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
7209            }),
7210            cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
7211            warnings: vec!["cache warmed".to_owned()],
7212            manifest_documents: JsonValue::Array(vec![
7213                serde_json::json!({
7214                    "PackageIdentifier": "Test.Package",
7215                    "PackageVersion": "1.2.3",
7216                    "DefaultLocale": "en-US",
7217                    "ManifestType": "version",
7218                    "ManifestVersion": "1.10.0"
7219                }),
7220                serde_json::json!({
7221                    "PackageIdentifier": "Test.Package",
7222                    "PackageVersion": "1.2.3",
7223                    "PackageLocale": "en-US",
7224                    "PackageName": "Test Package",
7225                    "Publisher": "Example",
7226                    "License": "MIT",
7227                    "ShortDescription": "Structured output",
7228                    "ManifestType": "defaultLocale",
7229                    "ManifestVersion": "1.10.0"
7230                }),
7231                serde_json::json!({
7232                    "PackageIdentifier": "Test.Package",
7233                    "PackageVersion": "1.2.3",
7234                    "ManifestType": "installer",
7235                    "ManifestVersion": "1.10.0",
7236                    "Installers": [
7237                        {
7238                            "Architecture": "x64",
7239                            "InstallerType": "msix",
7240                            "InstallerUrl": "https://example.test/Test.Package.msix",
7241                            "InstallerSha256": "ABC123",
7242                            "Commands": ["testpkg"],
7243                            "InstallerSwitches": { "Silent": "/quiet" },
7244                            "Dependencies": {
7245                                "PackageDependencies": [
7246                                    { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
7247                                ]
7248                            }
7249                        }
7250                    ]
7251                }),
7252            ]),
7253        };
7254
7255        let document = result.structured_document();
7256
7257        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
7258        assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
7259        assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
7260        assert_eq!(
7261            document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
7262            Some("Microsoft.VCRedist.2015+.x64")
7263        );
7264        assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
7265        assert_eq!(
7266            document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
7267            Some("/quiet")
7268        );
7269    }
7270
7271    #[test]
7272    fn parse_yaml_manifest_bundle_returns_singleton_document() {
7273        let yaml = r#"
7274PackageIdentifier: Test.Package
7275PackageVersion: 1.2.3
7276DefaultLocale: en-US
7277ManifestType: version
7278ManifestVersion: 1.10.0
7279---
7280PackageIdentifier: Test.Package
7281PackageVersion: 1.2.3
7282PackageLocale: en-US
7283PackageName: Test Package
7284Publisher: Example
7285License: MIT
7286ShortDescription: Structured output
7287ManifestType: defaultLocale
7288ManifestVersion: 1.10.0
7289---
7290PackageIdentifier: Test.Package
7291PackageVersion: 1.2.3
7292ManifestType: installer
7293ManifestVersion: 1.10.0
7294Installers:
7295  - Architecture: x64
7296    InstallerType: exe
7297    InstallerUrl: https://example.test/Test.Package.exe
7298    InstallerSha256: ABC123
7299"#;
7300
7301        let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
7302
7303        assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
7304        assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
7305        assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
7306    }
7307
7308    #[test]
7309    fn collapse_structured_documents_returns_plural_show_documents() {
7310        let documents = collapse_structured_documents(&[
7311            JsonValue::Array(vec![
7312                serde_json::json!({
7313                    "PackageIdentifier": "Test.Package.One",
7314                    "PackageVersion": "1.0.0",
7315                    "DefaultLocale": "en-US",
7316                    "ManifestType": "version",
7317                    "ManifestVersion": "1.10.0"
7318                }),
7319                serde_json::json!({
7320                    "PackageIdentifier": "Test.Package.One",
7321                    "PackageVersion": "1.0.0",
7322                    "PackageLocale": "en-US",
7323                    "PackageName": "Test Package One",
7324                    "ManifestType": "defaultLocale",
7325                    "ManifestVersion": "1.10.0"
7326                }),
7327                serde_json::json!({
7328                    "PackageIdentifier": "Test.Package.One",
7329                    "PackageVersion": "1.0.0",
7330                    "ManifestType": "installer",
7331                    "ManifestVersion": "1.10.0",
7332                    "Installers": [
7333                        {
7334                            "Architecture": "x64",
7335                            "InstallerType": "exe",
7336                            "InstallerUrl": "https://example.test/one.exe",
7337                            "InstallerSha256": "ABC123"
7338                        }
7339                    ]
7340                }),
7341            ]),
7342            serde_json::json!({
7343                "PackageIdentifier": "Test.Package.Two",
7344                "PackageVersion": "2.0.0",
7345                "PackageLocale": "en-US",
7346                "PackageName": "Test Package Two",
7347                "ManifestType": "singleton",
7348                "ManifestVersion": "1.12.0"
7349            }),
7350        ]);
7351
7352        assert_eq!(documents.len(), 2);
7353        assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
7354        assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
7355        assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
7356        assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
7357        assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
7358    }
7359
7360    #[test]
7361    fn unsupported_action_result_marks_noop_and_warning() {
7362        let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
7363
7364        assert!(result.success);
7365        assert!(result.no_op);
7366        assert_eq!(result.exit_code, 0);
7367        assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
7368    }
7369
7370    #[test]
7371    fn create_install_no_op_result_honors_no_upgrade() {
7372        let mut request = InstallRequest::new(PackageQuery {
7373            id: Some("Contoso.App".to_owned()),
7374            ..PackageQuery::default()
7375        });
7376        request.no_upgrade = true;
7377
7378        let manifest = Manifest {
7379            id: "Contoso.App".to_owned(),
7380            name: "Contoso App".to_owned(),
7381            version: "2.0.0".to_owned(),
7382            channel: String::new(),
7383            publisher: None,
7384            description: None,
7385            moniker: None,
7386            package_url: None,
7387            publisher_url: None,
7388            publisher_support_url: None,
7389            license: None,
7390            license_url: None,
7391            privacy_url: None,
7392            author: None,
7393            copyright: None,
7394            copyright_url: None,
7395            release_notes: None,
7396            release_notes_url: None,
7397            tags: Vec::new(),
7398            agreements: Vec::new(),
7399            package_dependencies: Vec::new(),
7400            documentation: Vec::new(),
7401            installers: Vec::new(),
7402        };
7403        let existing = ListMatch {
7404            name: "Contoso App".to_owned(),
7405            id: "Contoso.App".to_owned(),
7406            local_id: "Contoso.App".to_owned(),
7407            installed_version: "1.0.0".to_owned(),
7408            available_version: Some("2.0.0".to_owned()),
7409            source_name: Some("winget".to_owned()),
7410            publisher: None,
7411            scope: None,
7412            installer_category: None,
7413            install_location: None,
7414            package_family_names: Vec::new(),
7415            product_codes: Vec::new(),
7416            upgrade_codes: Vec::new(),
7417        };
7418
7419        let result =
7420            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
7421
7422        assert!(result.success);
7423        assert!(result.no_op);
7424        assert_eq!(result.version, "1.0.0");
7425        assert_eq!(
7426            result.warnings,
7427            vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
7428        );
7429    }
7430
7431    #[test]
7432    fn create_install_no_op_result_skips_reinstall_when_current() {
7433        let request = InstallRequest::new(PackageQuery {
7434            id: Some("Contoso.App".to_owned()),
7435            ..PackageQuery::default()
7436        });
7437
7438        let manifest = Manifest {
7439            id: "Contoso.App".to_owned(),
7440            name: "Contoso App".to_owned(),
7441            version: "2.0.0".to_owned(),
7442            channel: String::new(),
7443            publisher: None,
7444            description: None,
7445            moniker: None,
7446            package_url: None,
7447            publisher_url: None,
7448            publisher_support_url: None,
7449            license: None,
7450            license_url: None,
7451            privacy_url: None,
7452            author: None,
7453            copyright: None,
7454            copyright_url: None,
7455            release_notes: None,
7456            release_notes_url: None,
7457            tags: Vec::new(),
7458            agreements: Vec::new(),
7459            package_dependencies: Vec::new(),
7460            documentation: Vec::new(),
7461            installers: Vec::new(),
7462        };
7463        let existing = ListMatch {
7464            name: "Contoso App".to_owned(),
7465            id: "Contoso.App".to_owned(),
7466            local_id: "Contoso.App".to_owned(),
7467            installed_version: "2.0.0".to_owned(),
7468            available_version: Some("2.0.0".to_owned()),
7469            source_name: Some("winget".to_owned()),
7470            publisher: None,
7471            scope: None,
7472            installer_category: None,
7473            install_location: None,
7474            package_family_names: Vec::new(),
7475            product_codes: Vec::new(),
7476            upgrade_codes: Vec::new(),
7477        };
7478
7479        let result =
7480            Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
7481
7482        assert!(result.success);
7483        assert!(result.no_op);
7484        assert_eq!(result.version, "2.0.0");
7485        assert_eq!(
7486            result.warnings,
7487            vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
7488        );
7489    }
7490
7491    #[test]
7492    fn collapse_structured_document_projects_merged_manifest() {
7493        let document = collapse_structured_document(&serde_json::json!({
7494            "PackageIdentifier": "Test.Package",
7495            "PackageVersion": "1.2.3",
7496            "PackageLocale": "en-US",
7497            "PackageName": "Test Package",
7498            "Publisher": "Example",
7499            "InstallerType": "exe",
7500            "Installers": [
7501                {
7502                    "Architecture": "x64",
7503                    "InstallerUrl": "https://example.test/Test.Package.exe",
7504                    "InstallerSha256": "ABC123"
7505                }
7506            ],
7507            "ManifestType": "merged",
7508            "ManifestVersion": "1.10.0"
7509        }));
7510
7511        assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
7512        assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
7513        assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
7514        assert_eq!(document["InstallerType"].as_str(), Some("exe"));
7515        assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
7516    }
7517
7518    #[test]
7519    fn parses_fixture_manifest() {
7520        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
7521            .join("tests")
7522            .join("fixtures")
7523            .join("ManifestV1_28-Singleton.yaml");
7524        let bytes = fs::read(root).expect("fixture bytes");
7525        let manifest = parse_yaml_manifest(&bytes).expect("manifest");
7526        assert_eq!(manifest.id, "microsoft.msixsdk");
7527        assert_eq!(manifest.version, "1.7.32");
7528        assert_eq!(manifest.name, "MSIX SDK");
7529        assert!(!manifest.installers.is_empty());
7530    }
7531
7532    #[test]
7533    fn parses_installer_switches_from_yaml_manifest() {
7534        let yaml = r#"
7535PackageIdentifier: Test.Package
7536PackageVersion: 1.2.3
7537PackageName: Test Package
7538InstallerSwitches:
7539  SilentWithProgress: /SILENT
7540Installers:
7541  - Architecture: x64
7542    InstallerType: inno
7543    InstallerUrl: https://example.test/Test.Package.exe
7544    InstallerSha256: ABC123
7545    InstallerSwitches:
7546      Silent: /VERYSILENT
7547      Interactive: /HELP
7548"#;
7549
7550        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
7551        let installer = &manifest.installers[0];
7552
7553        assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
7554        assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
7555        assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
7556    }
7557
7558    #[test]
7559    fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
7560        let yaml = r#"
7561PackageIdentifier: Test.Package
7562PackageVersion: 1.2.3
7563PackageName: Test Package
7564Platform:
7565  - Windows.Desktop
7566MinimumOSVersion: 10.0.19041.0
7567Installers:
7568  - Architecture: x64
7569    InstallerType: exe
7570    InstallerUrl: https://example.test/Test.Package.exe
7571    InstallerSha256: ABC123
7572  - Architecture: x64
7573    InstallerType: msix
7574    Platform:
7575      - Windows.Universal
7576    MinimumOSVersion: 10.0.22621.0
7577    InstallerUrl: https://example.test/Test.Package.msix
7578    InstallerSha256: DEF456
7579"#;
7580
7581        let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
7582
7583        assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
7584        assert_eq!(
7585            manifest.installers[0].minimum_os_version.as_deref(),
7586            Some("10.0.19041.0")
7587        );
7588        assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
7589        assert_eq!(
7590            manifest.installers[1].minimum_os_version.as_deref(),
7591            Some("10.0.22621.0")
7592        );
7593    }
7594
7595    #[test]
7596    fn installer_switch_arguments_prefer_manifest_switches() {
7597        let installer = Installer {
7598            architecture: None,
7599            installer_type: Some("inno".to_owned()),
7600            url: None,
7601            sha256: None,
7602            product_code: None,
7603            locale: None,
7604            scope: None,
7605            release_date: None,
7606            package_family_name: None,
7607            upgrade_code: None,
7608            platforms: Vec::new(),
7609            minimum_os_version: None,
7610            switches: InstallerSwitches {
7611                silent: Some("/mysilent".to_owned()),
7612                silent_with_progress: Some("/mysilentwithprogress".to_owned()),
7613                interactive: Some("/myinteractive".to_owned()),
7614                ..InstallerSwitches::default()
7615            },
7616            commands: Vec::new(),
7617            package_dependencies: Vec::new(),
7618        };
7619        let manifest = Manifest {
7620            id: "Test.Package".to_owned(),
7621            name: "Test Package".to_owned(),
7622            version: "1.0.0".to_owned(),
7623            channel: String::new(),
7624            publisher: None,
7625            description: None,
7626            moniker: None,
7627            package_url: None,
7628            publisher_url: None,
7629            publisher_support_url: None,
7630            license: None,
7631            license_url: None,
7632            privacy_url: None,
7633            author: None,
7634            copyright: None,
7635            copyright_url: None,
7636            release_notes: None,
7637            release_notes_url: None,
7638            tags: Vec::new(),
7639            agreements: Vec::new(),
7640            package_dependencies: Vec::new(),
7641            documentation: Vec::new(),
7642            installers: Vec::new(),
7643        };
7644
7645        let mut silent_request = InstallRequest::new(PackageQuery::default());
7646        silent_request.mode = InstallerMode::Silent;
7647        let mut progress_request = InstallRequest::new(PackageQuery::default());
7648        progress_request.mode = InstallerMode::SilentWithProgress;
7649        let mut interactive_request = InstallRequest::new(PackageQuery::default());
7650        interactive_request.mode = InstallerMode::Interactive;
7651        assert_eq!(
7652            installer_command_arguments(
7653                "inno",
7654                &silent_request,
7655                &manifest,
7656                Path::new("installer.exe"),
7657                &installer
7658            ),
7659            vec!["/mysilent".to_owned()]
7660        );
7661        assert_eq!(
7662            installer_command_arguments(
7663                "inno",
7664                &progress_request,
7665                &manifest,
7666                Path::new("installer.exe"),
7667                &installer
7668            ),
7669            vec!["/mysilentwithprogress".to_owned()]
7670        );
7671        assert_eq!(
7672            installer_command_arguments(
7673                "inno",
7674                &interactive_request,
7675                &manifest,
7676                Path::new("installer.exe"),
7677                &installer
7678            ),
7679            vec!["/myinteractive".to_owned()]
7680        );
7681    }
7682
7683    #[test]
7684    fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
7685        let installer = Installer {
7686            architecture: None,
7687            installer_type: Some("inno".to_owned()),
7688            url: None,
7689            sha256: None,
7690            product_code: None,
7691            locale: None,
7692            scope: None,
7693            release_date: None,
7694            package_family_name: None,
7695            upgrade_code: None,
7696            platforms: Vec::new(),
7697            minimum_os_version: None,
7698            switches: InstallerSwitches::default(),
7699            commands: Vec::new(),
7700            package_dependencies: Vec::new(),
7701        };
7702        let manifest = Manifest {
7703            id: "Test.Package".to_owned(),
7704            name: "Test Package".to_owned(),
7705            version: "1.0.0".to_owned(),
7706            channel: String::new(),
7707            publisher: None,
7708            description: None,
7709            moniker: None,
7710            package_url: None,
7711            publisher_url: None,
7712            publisher_support_url: None,
7713            license: None,
7714            license_url: None,
7715            privacy_url: None,
7716            author: None,
7717            copyright: None,
7718            copyright_url: None,
7719            release_notes: None,
7720            release_notes_url: None,
7721            tags: Vec::new(),
7722            agreements: Vec::new(),
7723            package_dependencies: Vec::new(),
7724            documentation: Vec::new(),
7725            installers: Vec::new(),
7726        };
7727        let mut progress_request = InstallRequest::new(PackageQuery::default());
7728        progress_request.mode = InstallerMode::SilentWithProgress;
7729        let mut silent_request = InstallRequest::new(PackageQuery::default());
7730        silent_request.mode = InstallerMode::Silent;
7731
7732        assert_eq!(
7733            installer_command_arguments(
7734                "inno",
7735                &progress_request,
7736                &manifest,
7737                Path::new("installer.exe"),
7738                &installer
7739            ),
7740            vec![
7741                "/SP-".to_owned(),
7742                "/SILENT".to_owned(),
7743                "/SUPPRESSMSGBOXES".to_owned(),
7744                "/NORESTART".to_owned()
7745            ]
7746        );
7747        assert_eq!(
7748            installer_command_arguments(
7749                "inno",
7750                &silent_request,
7751                &manifest,
7752                Path::new("installer.exe"),
7753                &installer
7754            ),
7755            vec![
7756                "/SP-".to_owned(),
7757                "/VERYSILENT".to_owned(),
7758                "/SUPPRESSMSGBOXES".to_owned(),
7759                "/NORESTART".to_owned()
7760            ]
7761        );
7762    }
7763
7764    #[test]
7765    fn installer_command_arguments_append_manifest_and_cli_switches() {
7766        let installer = Installer {
7767            architecture: None,
7768            installer_type: Some("msi".to_owned()),
7769            url: None,
7770            sha256: None,
7771            product_code: None,
7772            locale: None,
7773            scope: None,
7774            release_date: None,
7775            package_family_name: None,
7776            upgrade_code: None,
7777            platforms: Vec::new(),
7778            minimum_os_version: None,
7779            switches: InstallerSwitches {
7780                custom: Some("ADDLOCAL=Core".to_owned()),
7781                log: Some("/log \"<LOGPATH>\"".to_owned()),
7782                install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
7783                ..InstallerSwitches::default()
7784            },
7785            commands: Vec::new(),
7786            package_dependencies: Vec::new(),
7787        };
7788        let manifest = Manifest {
7789            id: "ShareX.ShareX".to_owned(),
7790            name: "ShareX".to_owned(),
7791            version: "19.0.2".to_owned(),
7792            channel: String::new(),
7793            publisher: None,
7794            description: None,
7795            moniker: None,
7796            package_url: None,
7797            publisher_url: None,
7798            publisher_support_url: None,
7799            license: None,
7800            license_url: None,
7801            privacy_url: None,
7802            author: None,
7803            copyright: None,
7804            copyright_url: None,
7805            release_notes: None,
7806            release_notes_url: None,
7807            tags: Vec::new(),
7808            agreements: Vec::new(),
7809            package_dependencies: Vec::new(),
7810            documentation: Vec::new(),
7811            installers: Vec::new(),
7812        };
7813        let mut request = InstallRequest::new(PackageQuery::default());
7814        request.mode = InstallerMode::Silent;
7815        request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
7816        request.custom = Some("REBOOT=ReallySuppress".to_owned());
7817        request.install_location = Some(r"C:\Apps\ShareX".to_owned());
7818
7819        assert_eq!(
7820            installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
7821            vec![
7822                "/i".to_owned(),
7823                r"C:\temp\ShareX.msi".to_owned(),
7824                "/quiet".to_owned(),
7825                "/norestart".to_owned(),
7826                "/log".to_owned(),
7827                r"C:\temp\winget.log".to_owned(),
7828                "ADDLOCAL=Core".to_owned(),
7829                "REBOOT=ReallySuppress".to_owned(),
7830                r"TARGETDIR=C:\Apps\ShareX".to_owned(),
7831            ]
7832        );
7833    }
7834
7835    #[test]
7836    fn decompresses_mszyml_payload() {
7837        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
7838        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
7839        encoder.write_all(payload.as_bytes()).expect("write payload");
7840        let compressed = encoder.finish().expect("finish payload");
7841        let mut mszip = b"CK".to_vec();
7842        mszip.extend_from_slice(&compressed);
7843
7844        let decompressed = decompress_mszyml(&mszip).expect("decompress");
7845        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
7846        assert_eq!(parsed.versions[0].version, "1.2.3");
7847    }
7848
7849    #[test]
7850    fn decompresses_mszyml_payload_with_prefix() {
7851        let payload = "sV: 1.0.0\nvD:\n  - v: 1.2.3\n    rP: manifests/test.yaml\n    s256H: ABCD\n";
7852        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
7853        encoder.write_all(payload.as_bytes()).expect("write payload");
7854        let compressed = encoder.finish().expect("finish payload");
7855        let mut prefixed = vec![0u8; 28];
7856        prefixed.extend_from_slice(b"CK");
7857        prefixed.extend_from_slice(&compressed);
7858
7859        let decompressed = decompress_mszyml(&prefixed).expect("decompress");
7860        let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
7861        assert_eq!(parsed.versions[0].version, "1.2.3");
7862    }
7863
7864    #[test]
7865    fn compares_versions_naturally() {
7866        assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
7867        assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
7868        assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
7869    }
7870
7871    #[test]
7872    fn installer_hash_verification_fails_on_mismatch_without_override() {
7873        let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
7874        assert!(result.is_err());
7875        assert!(
7876            result
7877                .expect_err("hash mismatch")
7878                .to_string()
7879                .contains("Installer hash mismatch")
7880        );
7881    }
7882
7883    #[test]
7884    fn installer_hash_verification_allows_mismatch_with_override() {
7885        verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
7886        verify_installer_hash(None, b"pinget", false).expect("missing hash");
7887    }
7888
7889    #[test]
7890    fn search_query_many_includes_tag_and_command_conditions() {
7891        let query = PackageQuery {
7892            query: Some("terminal".to_owned()),
7893            ..PackageQuery::default()
7894        };
7895
7896        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
7897
7898        assert!(where_clause.contains("tags2"));
7899        assert!(where_clause.contains("commands2"));
7900        assert_eq!(params.len(), 5);
7901    }
7902
7903    #[test]
7904    fn show_query_single_omits_tag_and_command_conditions() {
7905        let query = PackageQuery {
7906            query: Some("terminal".to_owned()),
7907            ..PackageQuery::default()
7908        };
7909
7910        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
7911
7912        assert!(!where_clause.contains("tags2"));
7913        assert!(!where_clause.contains("commands2"));
7914        assert_eq!(params.len(), 3);
7915    }
7916
7917    #[test]
7918    fn explicit_tag_filter_uses_exact_match() {
7919        let query = PackageQuery {
7920            tag: Some("terminal".to_owned()),
7921            ..PackageQuery::default()
7922        };
7923
7924        let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
7925
7926        assert!(where_clause.contains("tags2"));
7927        assert_eq!(params, vec!["terminal".to_owned()]);
7928    }
7929
7930    #[test]
7931    fn rest_tag_filter_uses_exact_match() {
7932        let query = PackageQuery {
7933            tag: Some("terminal".to_owned()),
7934            ..PackageQuery::default()
7935        };
7936        let info = RestInformation {
7937            required_package_match_fields: Vec::new(),
7938            unsupported_package_match_fields: Vec::new(),
7939            required_query_parameters: Vec::new(),
7940            ..RestInformation::default()
7941        };
7942
7943        let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
7944        let filters = body["Filters"].as_array().expect("filters");
7945
7946        assert_eq!(filters.len(), 1);
7947        assert_eq!(filters[0]["PackageMatchField"], "Tag");
7948        assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
7949        assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
7950    }
7951
7952    #[test]
7953    fn selects_installer_using_requested_filters() {
7954        let installers = vec![
7955            Installer {
7956                architecture: Some("x86".to_owned()),
7957                installer_type: Some("zip".to_owned()),
7958                url: None,
7959                sha256: None,
7960                product_code: None,
7961                locale: Some("en-US".to_owned()),
7962                scope: Some("user".to_owned()),
7963                release_date: None,
7964                package_family_name: None,
7965                upgrade_code: None,
7966                platforms: Vec::new(),
7967                minimum_os_version: None,
7968                switches: InstallerSwitches::default(),
7969                commands: Vec::new(),
7970                package_dependencies: Vec::new(),
7971            },
7972            Installer {
7973                architecture: Some("x64".to_owned()),
7974                installer_type: Some("msix".to_owned()),
7975                url: None,
7976                sha256: None,
7977                product_code: None,
7978                locale: Some("en-US".to_owned()),
7979                scope: Some("user".to_owned()),
7980                release_date: None,
7981                package_family_name: None,
7982                upgrade_code: None,
7983                platforms: Vec::new(),
7984                minimum_os_version: None,
7985                switches: InstallerSwitches::default(),
7986                commands: vec!["demo".to_owned()],
7987                package_dependencies: Vec::new(),
7988            },
7989        ];
7990        let query = PackageQuery {
7991            installer_type: Some("msix".to_owned()),
7992            installer_architecture: Some("x64".to_owned()),
7993            install_scope: Some("user".to_owned()),
7994            locale: Some("en-US".to_owned()),
7995            ..PackageQuery::default()
7996        };
7997
7998        let selected = select_installer(&installers, &query).expect("selected installer");
7999        assert_eq!(selected.installer_type.as_deref(), Some("msix"));
8000        assert_eq!(selected.architecture.as_deref(), Some("x64"));
8001    }
8002
8003    #[test]
8004    fn selects_first_installer_when_rank_ties() {
8005        let installers = vec![
8006            Installer {
8007                architecture: Some("x64".to_owned()),
8008                installer_type: Some("exe".to_owned()),
8009                url: None,
8010                sha256: None,
8011                product_code: None,
8012                locale: Some("en-US".to_owned()),
8013                scope: Some("machine".to_owned()),
8014                release_date: None,
8015                package_family_name: None,
8016                upgrade_code: None,
8017                platforms: Vec::new(),
8018                minimum_os_version: None,
8019                switches: InstallerSwitches::default(),
8020                commands: Vec::new(),
8021                package_dependencies: Vec::new(),
8022            },
8023            Installer {
8024                architecture: Some("x64".to_owned()),
8025                installer_type: Some("exe".to_owned()),
8026                url: None,
8027                sha256: None,
8028                product_code: None,
8029                locale: Some("en-US".to_owned()),
8030                scope: Some("user".to_owned()),
8031                release_date: None,
8032                package_family_name: None,
8033                upgrade_code: None,
8034                platforms: Vec::new(),
8035                minimum_os_version: None,
8036                switches: InstallerSwitches::default(),
8037                commands: Vec::new(),
8038                package_dependencies: Vec::new(),
8039            },
8040        ];
8041
8042        let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
8043        assert_eq!(selected.scope.as_deref(), Some("machine"));
8044    }
8045
8046    #[test]
8047    fn selects_installer_using_requested_platform() {
8048        let installers = vec![
8049            Installer {
8050                architecture: Some("x64".to_owned()),
8051                installer_type: Some("exe".to_owned()),
8052                url: None,
8053                sha256: None,
8054                product_code: None,
8055                locale: Some("en-US".to_owned()),
8056                scope: Some("user".to_owned()),
8057                release_date: None,
8058                package_family_name: None,
8059                upgrade_code: None,
8060                platforms: vec!["Windows.Universal".to_owned()],
8061                minimum_os_version: None,
8062                switches: InstallerSwitches::default(),
8063                commands: Vec::new(),
8064                package_dependencies: Vec::new(),
8065            },
8066            Installer {
8067                architecture: Some("x64".to_owned()),
8068                installer_type: Some("exe".to_owned()),
8069                url: None,
8070                sha256: None,
8071                product_code: None,
8072                locale: Some("en-US".to_owned()),
8073                scope: Some("user".to_owned()),
8074                release_date: None,
8075                package_family_name: None,
8076                upgrade_code: None,
8077                platforms: vec!["Windows.Desktop".to_owned()],
8078                minimum_os_version: None,
8079                switches: InstallerSwitches::default(),
8080                commands: Vec::new(),
8081                package_dependencies: Vec::new(),
8082            },
8083        ];
8084        let query = PackageQuery {
8085            installer_type: Some("exe".to_owned()),
8086            platform: Some("Windows.Desktop".to_owned()),
8087            ..PackageQuery::default()
8088        };
8089
8090        let selected = select_installer(&installers, &query).expect("selected installer");
8091        assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
8092    }
8093
8094    #[test]
8095    fn selects_installer_using_requested_os_version() {
8096        let installers = vec![
8097            Installer {
8098                architecture: Some("x64".to_owned()),
8099                installer_type: Some("exe".to_owned()),
8100                url: None,
8101                sha256: None,
8102                product_code: None,
8103                locale: Some("en-US".to_owned()),
8104                scope: Some("user".to_owned()),
8105                release_date: None,
8106                package_family_name: None,
8107                upgrade_code: None,
8108                platforms: Vec::new(),
8109                minimum_os_version: Some("10.0.22621.0".to_owned()),
8110                switches: InstallerSwitches::default(),
8111                commands: Vec::new(),
8112                package_dependencies: Vec::new(),
8113            },
8114            Installer {
8115                architecture: Some("x64".to_owned()),
8116                installer_type: Some("exe".to_owned()),
8117                url: None,
8118                sha256: None,
8119                product_code: None,
8120                locale: Some("en-US".to_owned()),
8121                scope: Some("user".to_owned()),
8122                release_date: None,
8123                package_family_name: None,
8124                upgrade_code: None,
8125                platforms: Vec::new(),
8126                minimum_os_version: Some("10.0.19041.0".to_owned()),
8127                switches: InstallerSwitches::default(),
8128                commands: Vec::new(),
8129                package_dependencies: Vec::new(),
8130            },
8131        ];
8132        let query = PackageQuery {
8133            installer_type: Some("exe".to_owned()),
8134            os_version: Some("10.0.19045.0".to_owned()),
8135            ..PackageQuery::default()
8136        };
8137
8138        let selected = select_installer(&installers, &query).expect("selected installer");
8139        assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
8140    }
8141
8142    #[test]
8143    fn derives_correlation_name_candidates() {
8144        let candidates = correlation_name_candidates("PowerToys (Preview) x64");
8145        assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
8146        assert!(candidates.contains(&"PowerToys".to_owned()));
8147    }
8148
8149    #[test]
8150    fn correlates_installed_package_to_available_match() {
8151        let installed = InstalledPackage {
8152            name: "PowerToys (Preview) x64".to_owned(),
8153            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
8154            installed_version: "0.98.1".to_owned(),
8155            publisher: None,
8156            scope: Some("Machine".to_owned()),
8157            installer_category: Some("exe".to_owned()),
8158            install_location: None,
8159            package_family_names: Vec::new(),
8160            product_codes: Vec::new(),
8161            upgrade_codes: Vec::new(),
8162            correlated: None,
8163        };
8164        let candidates = vec![SearchMatch {
8165            source_name: "winget".to_owned(),
8166            source_kind: SourceKind::PreIndexed,
8167            id: "Microsoft.PowerToys".to_owned(),
8168            name: "PowerToys".to_owned(),
8169            moniker: None,
8170            version: Some("0.98.1".to_owned()),
8171            channel: None,
8172            match_criteria: None,
8173        }];
8174
8175        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
8176        assert_eq!(correlated.id, "Microsoft.PowerToys");
8177    }
8178
8179    #[test]
8180    fn list_query_uses_available_lookup_for_tag_filters() {
8181        let query = ListQuery {
8182            tag: Some("terminal".to_owned()),
8183            ..ListQuery::default()
8184        };
8185
8186        assert!(list_query_needs_available_lookup(&query));
8187    }
8188
8189    #[test]
8190    fn list_tag_filter_requires_correlated_match() {
8191        let package = InstalledPackage {
8192            name: "PowerToys (Preview) x64".to_owned(),
8193            local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
8194            installed_version: "0.98.1".to_owned(),
8195            publisher: None,
8196            scope: Some("Machine".to_owned()),
8197            installer_category: Some("exe".to_owned()),
8198            install_location: None,
8199            package_family_names: Vec::new(),
8200            product_codes: Vec::new(),
8201            upgrade_codes: Vec::new(),
8202            correlated: None,
8203        };
8204        let query = ListQuery {
8205            tag: Some("powertoys".to_owned()),
8206            ..ListQuery::default()
8207        };
8208
8209        assert!(!list_package_matches(&package, &query));
8210    }
8211
8212    #[test]
8213    fn list_lookup_ignores_display_count() {
8214        let query = ListQuery {
8215            query: Some("git".to_owned()),
8216            count: Some(5),
8217            ..ListQuery::default()
8218        };
8219
8220        let package_query = package_query_from_list_query(&query);
8221        assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
8222    }
8223
8224    #[test]
8225    fn correlation_tiebreaks_on_unnormalized_name_prefix() {
8226        // `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
8227        // strips the trailing punctuation). Without a tiebreaker, max_by_key returned
8228        // whichever candidate happened to come last in iteration order. The installed
8229        // display name's prefix decides the correct one.
8230        let installed = InstalledPackage {
8231            name: "Notepad++ (ARM 64-bit)".to_owned(),
8232            local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
8233            installed_version: "8.8.9".to_owned(),
8234            publisher: None,
8235            scope: Some("Machine".to_owned()),
8236            installer_category: Some("exe".to_owned()),
8237            install_location: None,
8238            package_family_names: Vec::new(),
8239            product_codes: Vec::new(),
8240            upgrade_codes: Vec::new(),
8241            correlated: None,
8242        };
8243        let candidates = vec![
8244            SearchMatch {
8245                source_name: "winget".to_owned(),
8246                source_kind: SourceKind::PreIndexed,
8247                id: "ndd.Notepad--".to_owned(),
8248                name: "Notepad--".to_owned(),
8249                moniker: None,
8250                version: Some("1.0.0".to_owned()),
8251                channel: None,
8252                match_criteria: None,
8253            },
8254            SearchMatch {
8255                source_name: "winget".to_owned(),
8256                source_kind: SourceKind::PreIndexed,
8257                id: "Notepad++.Notepad++".to_owned(),
8258                name: "Notepad++".to_owned(),
8259                moniker: None,
8260                version: Some("8.9.5".to_owned()),
8261                channel: None,
8262                match_criteria: None,
8263            },
8264        ];
8265
8266        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
8267        assert_eq!(correlated.id, "Notepad++.Notepad++");
8268    }
8269
8270    #[test]
8271    fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
8272        // Without the prefix bonus, the loose substring match picked up `Studio`
8273        // anywhere in the installed name — so `ZeroBrane.Studio` would outrank the
8274        // genuinely-related `Microsoft.DotNet.SDK.10` because both scored 700 and
8275        // sort order favored the alphabetically-later candidate.
8276        let installed = InstalledPackage {
8277            name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
8278            local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
8279            installed_version: "40.10.18029".to_owned(),
8280            publisher: None,
8281            scope: Some("Machine".to_owned()),
8282            installer_category: Some("msi".to_owned()),
8283            install_location: None,
8284            package_family_names: Vec::new(),
8285            product_codes: Vec::new(),
8286            upgrade_codes: Vec::new(),
8287            correlated: None,
8288        };
8289        let candidates = vec![
8290            SearchMatch {
8291                source_name: "winget".to_owned(),
8292                source_kind: SourceKind::PreIndexed,
8293                id: "ZeroBrane.Studio".to_owned(),
8294                name: "Studio".to_owned(),
8295                moniker: None,
8296                version: Some("1.0".to_owned()),
8297                channel: None,
8298                match_criteria: None,
8299            },
8300            SearchMatch {
8301                source_name: "winget".to_owned(),
8302                source_kind: SourceKind::PreIndexed,
8303                id: "BrickLink.Studio".to_owned(),
8304                name: "Studio".to_owned(),
8305                moniker: None,
8306                version: Some("1.0".to_owned()),
8307                channel: None,
8308                match_criteria: None,
8309            },
8310            SearchMatch {
8311                source_name: "winget".to_owned(),
8312                source_kind: SourceKind::PreIndexed,
8313                id: "Microsoft.DotNet.SDK.10".to_owned(),
8314                name: "Microsoft .NET SDK 10.0".to_owned(),
8315                moniker: None,
8316                version: Some("10.0.204".to_owned()),
8317                channel: None,
8318                match_criteria: None,
8319            },
8320        ];
8321
8322        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
8323        assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
8324    }
8325
8326    #[test]
8327    fn msix_packages_correlate_by_name() {
8328        // Previously hard-skipped via `package.local_id.starts_with("MSIX\\")` →
8329        // None, which prevented obvious MSIX updates (Microsoft.Teams etc.) from
8330        // ever surfacing. Name-based correlation now runs uniformly.
8331        let installed = InstalledPackage {
8332            name: "Microsoft Teams".to_owned(),
8333            local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
8334            installed_version: "25290.205.4069.4894".to_owned(),
8335            publisher: None,
8336            scope: Some("User".to_owned()),
8337            installer_category: Some("msix".to_owned()),
8338            install_location: None,
8339            package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
8340            product_codes: Vec::new(),
8341            upgrade_codes: Vec::new(),
8342            correlated: None,
8343        };
8344        let candidates = vec![SearchMatch {
8345            source_name: "winget".to_owned(),
8346            source_kind: SourceKind::PreIndexed,
8347            id: "Microsoft.Teams".to_owned(),
8348            name: "Microsoft Teams".to_owned(),
8349            moniker: None,
8350            version: Some("26106.1906.4665.7308".to_owned()),
8351            channel: None,
8352            match_criteria: None,
8353        }];
8354
8355        let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
8356        assert_eq!(correlated.id, "Microsoft.Teams");
8357    }
8358
8359    #[test]
8360    fn msix_with_resource_string_name_does_not_correlate() {
8361        // Some MSIX entries have unresolved resource-string display names
8362        // (e.g. `ms-resource:appDisplayName`). Those shouldn't latch onto an
8363        // unrelated catalog package via the loose substring rule.
8364        let installed = InstalledPackage {
8365            name: "ms-resource:appDisplayName".to_owned(),
8366            local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
8367            installed_version: "1.28.239.0".to_owned(),
8368            publisher: None,
8369            scope: Some("User".to_owned()),
8370            installer_category: Some("msix".to_owned()),
8371            install_location: None,
8372            package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
8373            product_codes: Vec::new(),
8374            upgrade_codes: Vec::new(),
8375            correlated: None,
8376        };
8377        let candidates = vec![SearchMatch {
8378            source_name: "winget".to_owned(),
8379            source_kind: SourceKind::PreIndexed,
8380            id: "Microsoft.AppInstaller".to_owned(),
8381            name: "App Installer".to_owned(),
8382            moniker: None,
8383            version: Some("1.28.240.0".to_owned()),
8384            channel: None,
8385            match_criteria: None,
8386        }];
8387
8388        assert!(correlate_installed_package(&installed, &candidates, true).is_none());
8389    }
8390
8391    #[test]
8392    fn strict_list_correlation_avoids_short_substring_matches() {
8393        let installed = InstalledPackage {
8394            name: "GitHub".to_owned(),
8395            local_id: r"ARP\User\X64\GitHub".to_owned(),
8396            installed_version: "1.0.0".to_owned(),
8397            publisher: None,
8398            scope: Some("User".to_owned()),
8399            installer_category: Some("exe".to_owned()),
8400            install_location: None,
8401            package_family_names: Vec::new(),
8402            product_codes: Vec::new(),
8403            upgrade_codes: Vec::new(),
8404            correlated: None,
8405        };
8406        let candidates = vec![SearchMatch {
8407            source_name: "winget".to_owned(),
8408            source_kind: SourceKind::PreIndexed,
8409            id: "Git.Git.PreRelease".to_owned(),
8410            name: "Git".to_owned(),
8411            moniker: None,
8412            version: Some("2.54.0".to_owned()),
8413            channel: None,
8414            match_criteria: None,
8415        }];
8416
8417        assert!(correlate_installed_package(&installed, &candidates, false).is_none());
8418    }
8419
8420    #[test]
8421    fn detects_available_upgrade_from_correlated_package() {
8422        let package = InstalledPackage {
8423            name: "AzCopy v10".to_owned(),
8424            local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
8425            installed_version: "10.32.2".to_owned(),
8426            publisher: None,
8427            scope: Some("Machine".to_owned()),
8428            installer_category: Some("exe".to_owned()),
8429            install_location: None,
8430            package_family_names: Vec::new(),
8431            product_codes: Vec::new(),
8432            upgrade_codes: Vec::new(),
8433            correlated: Some(SearchMatch {
8434                source_name: "winget".to_owned(),
8435                source_kind: SourceKind::PreIndexed,
8436                id: "Microsoft.Azure.AZCopy.10".to_owned(),
8437                name: "AzCopy v10".to_owned(),
8438                moniker: None,
8439                version: Some("10.32.3".to_owned()),
8440                channel: None,
8441                match_criteria: None,
8442            }),
8443        };
8444
8445        assert!(installed_package_has_upgrade(&package));
8446    }
8447
8448    #[test]
8449    fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
8450        let package = InstalledPackage {
8451            name: "Example Tool".to_owned(),
8452            local_id: r"ARP\User\X64\ExampleTool".to_owned(),
8453            installed_version: "Unknown".to_owned(),
8454            publisher: None,
8455            scope: Some("User".to_owned()),
8456            installer_category: Some("exe".to_owned()),
8457            install_location: None,
8458            package_family_names: Vec::new(),
8459            product_codes: Vec::new(),
8460            upgrade_codes: Vec::new(),
8461            correlated: Some(SearchMatch {
8462                source_name: "winget".to_owned(),
8463                source_kind: SourceKind::PreIndexed,
8464                id: "Contoso.ExampleTool".to_owned(),
8465                name: "Example Tool".to_owned(),
8466                moniker: None,
8467                version: Some("2.0.0".to_owned()),
8468                channel: None,
8469                match_criteria: None,
8470            }),
8471        };
8472        let query = ListQuery {
8473            upgrade_only: true,
8474            include_unknown: true,
8475            ..Default::default()
8476        };
8477
8478        assert!(installed_package_matches_upgrade_filter(&package, &query));
8479        assert_eq!(
8480            list_match_from_installed(package).available_version.as_deref(),
8481            Some("2.0.0")
8482        );
8483    }
8484
8485    #[test]
8486    fn create_repair_list_query_includes_installed_selectors() {
8487        let request = RepairRequest {
8488            query: PackageQuery {
8489                query: Some("powertoys".to_owned()),
8490                id: Some("Microsoft.PowerToys".to_owned()),
8491                name: Some("PowerToys".to_owned()),
8492                moniker: Some("powertoys".to_owned()),
8493                source: Some("winget".to_owned()),
8494                exact: true,
8495                version: Some("0.98.1".to_owned()),
8496                install_scope: Some("Machine".to_owned()),
8497                ..PackageQuery::default()
8498            },
8499            manifest_path: None,
8500            product_code: Some("{GUID}".to_owned()),
8501            mode: InstallerMode::Silent,
8502            log_path: Some(PathBuf::from("repair.log")),
8503            accept_package_agreements: true,
8504            force: true,
8505            ignore_security_hash: true,
8506        };
8507
8508        let query = Repository::create_repair_list_query(&request);
8509        assert_eq!(query.query.as_deref(), Some("powertoys"));
8510        assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
8511        assert_eq!(query.name.as_deref(), Some("PowerToys"));
8512        assert_eq!(query.moniker.as_deref(), Some("powertoys"));
8513        assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
8514        assert_eq!(query.version.as_deref(), Some("0.98.1"));
8515        assert_eq!(query.source.as_deref(), Some("winget"));
8516        assert!(query.exact);
8517        assert_eq!(query.install_scope.as_deref(), Some("Machine"));
8518        assert_eq!(query.count, Some(100));
8519    }
8520
8521    #[test]
8522    fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
8523        let request = RepairRequest {
8524            query: PackageQuery {
8525                name: Some("PowerToys".to_owned()),
8526                source: Some("winget".to_owned()),
8527                locale: Some("en-US".to_owned()),
8528                installer_architecture: Some("x64".to_owned()),
8529                install_scope: Some("Machine".to_owned()),
8530                ..PackageQuery::default()
8531            },
8532            manifest_path: None,
8533            product_code: None,
8534            mode: InstallerMode::SilentWithProgress,
8535            log_path: Some(PathBuf::from("repair.log")),
8536            accept_package_agreements: true,
8537            force: false,
8538            ignore_security_hash: true,
8539        };
8540        let installed = ListMatch {
8541            name: "PowerToys".to_owned(),
8542            id: "Microsoft.PowerToys".to_owned(),
8543            local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
8544            installed_version: "0.98.1".to_owned(),
8545            available_version: Some("0.98.2".to_owned()),
8546            source_name: Some("winget".to_owned()),
8547            publisher: None,
8548            scope: Some("Machine".to_owned()),
8549            installer_category: Some("exe".to_owned()),
8550            install_location: None,
8551            package_family_names: Vec::new(),
8552            product_codes: Vec::new(),
8553            upgrade_codes: Vec::new(),
8554        };
8555
8556        let install_request = Repository::create_repair_install_request(&request, Some(&installed));
8557        assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
8558        assert!(install_request.query.query.is_none());
8559        assert!(install_request.query.name.is_none());
8560        assert!(install_request.query.moniker.is_none());
8561        assert_eq!(install_request.query.source.as_deref(), Some("winget"));
8562        assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
8563        assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
8564        assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
8565        assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
8566        assert!(install_request.query.exact);
8567        assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
8568        assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
8569        assert!(install_request.accept_package_agreements);
8570        assert!(install_request.force);
8571        assert!(install_request.ignore_security_hash);
8572    }
8573
8574    #[test]
8575    fn source_scoped_pin_operations_round_trip() {
8576        let app_root = temp_app_root("pins");
8577        let result = (|| -> Result<()> {
8578            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8579            repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
8580            repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
8581            repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
8582
8583            assert_eq!(repository.list_pins(None)?.len(), 3);
8584            assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
8585            assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
8586
8587            assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
8588            assert_eq!(repository.list_pins(None)?.len(), 2);
8589            assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
8590
8591            repository.reset_pins(Some("test"))?;
8592            let remaining = repository.list_pins(None)?;
8593            assert_eq!(remaining.len(), 1);
8594            assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
8595
8596            repository.reset_pins(None)?;
8597            assert!(repository.list_pins(None)?.is_empty());
8598            Ok(())
8599        })();
8600
8601        let _ = fs::remove_dir_all(&app_root);
8602        result.expect("pin round trip");
8603    }
8604
8605    #[test]
8606    fn pin_operations_work_with_packaged_pin_schema() {
8607        let app_root = temp_app_root("pins_packaged_schema");
8608        let result = (|| -> Result<()> {
8609            let db_path = pins_db_path(&app_root);
8610            if let Some(parent) = db_path.parent() {
8611                fs::create_dir_all(parent)?;
8612            }
8613
8614            let conn = Connection::open(&db_path)?;
8615            conn.execute_batch(
8616                "CREATE TABLE pin (
8617                    package_id TEXT NOT NULL,
8618                    source_id TEXT NOT NULL,
8619                    type INTEGER NOT NULL,
8620                    version TEXT NOT NULL,
8621                    PRIMARY KEY (package_id, source_id)
8622                )",
8623            )?;
8624            drop(conn);
8625
8626            let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8627            repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
8628
8629            let pins = repository.list_pins(None)?;
8630            assert_eq!(pins.len(), 1);
8631            assert_eq!(pins[0].package_id, "Contoso.Tool");
8632            assert_eq!(pins[0].version, "1.2.*");
8633            assert_eq!(pins[0].source_id, "winget");
8634            assert_eq!(pins[0].pin_type, PinType::Gating);
8635
8636            let conn = Connection::open(&db_path)?;
8637            let stored_pin = conn.query_row(
8638                "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
8639                rusqlite::params!["Contoso.Tool", "winget"],
8640                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
8641            )?;
8642            assert_eq!(stored_pin.0, 3);
8643            assert_eq!(stored_pin.1, "1.2.*");
8644
8645            Ok(())
8646        })();
8647
8648        let _ = fs::remove_dir_all(&app_root);
8649        result.expect("packaged pin schema round trip");
8650    }
8651
8652    #[test]
8653    fn version_pin_patterns_match_exact_and_prefix_values() {
8654        assert!(version_matches_pin_pattern("1.2.3", "*"));
8655        assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
8656        assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
8657        assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
8658        assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
8659    }
8660
8661    #[test]
8662    fn source_specific_pins_override_source_agnostic_pins() {
8663        let item = ListMatch {
8664            name: "Contoso Tool".to_owned(),
8665            id: "Contoso.Tool".to_owned(),
8666            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
8667            installed_version: "1.0.0".to_owned(),
8668            available_version: Some("2.0.0".to_owned()),
8669            source_name: Some("winget".to_owned()),
8670            publisher: None,
8671            scope: None,
8672            installer_category: None,
8673            install_location: None,
8674            package_family_names: Vec::new(),
8675            product_codes: Vec::new(),
8676            upgrade_codes: Vec::new(),
8677        };
8678        let pins = vec![
8679            PinRecord {
8680                package_id: "Contoso.Tool".to_owned(),
8681                version: "*".to_owned(),
8682                source_id: String::new(),
8683                pin_type: PinType::Blocking,
8684            },
8685            PinRecord {
8686                package_id: "Contoso.Tool".to_owned(),
8687                version: "2.0.*".to_owned(),
8688                source_id: "winget".to_owned(),
8689                pin_type: PinType::Pinning,
8690            },
8691        ];
8692
8693        assert!(!is_upgrade_blocked_by_pin(&item, &pins));
8694    }
8695
8696    #[test]
8697    fn blocking_pin_blocks_upgrade() {
8698        let item = ListMatch {
8699            name: "Contoso Tool".to_owned(),
8700            id: "Contoso.Tool".to_owned(),
8701            local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
8702            installed_version: "1.0.0".to_owned(),
8703            available_version: Some("2.0.0".to_owned()),
8704            source_name: Some("winget".to_owned()),
8705            publisher: None,
8706            scope: None,
8707            installer_category: None,
8708            install_location: None,
8709            package_family_names: Vec::new(),
8710            product_codes: Vec::new(),
8711            upgrade_codes: Vec::new(),
8712        };
8713        let pins = vec![PinRecord {
8714            package_id: "Contoso.Tool".to_owned(),
8715            version: "*".to_owned(),
8716            source_id: "winget".to_owned(),
8717            pin_type: PinType::Blocking,
8718        }];
8719
8720        assert!(is_upgrade_blocked_by_pin(&item, &pins));
8721    }
8722
8723    #[cfg(windows)]
8724    #[test]
8725    fn parses_msix_package_full_name_into_version_and_family() {
8726        let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
8727            .expect("package metadata");
8728
8729        assert_eq!(parsed.version, "0.98.1.0");
8730        assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
8731    }
8732
8733    #[cfg(windows)]
8734    #[test]
8735    fn recognizes_windows_system_paths() {
8736        assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
8737        assert!(!is_windows_system_path(
8738            r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
8739        ));
8740    }
8741
8742    #[test]
8743    fn search_ranking_prefers_exact_name_over_tag_match() {
8744        let query = PackageQuery {
8745            query: Some("PowerToys".to_owned()),
8746            ..Default::default()
8747        };
8748        let exact_name = SearchMatch {
8749            source_name: "winget".to_owned(),
8750            source_kind: SourceKind::PreIndexed,
8751            id: "Microsoft.PowerToys".to_owned(),
8752            name: "PowerToys".to_owned(),
8753            moniker: Some("powertoys".to_owned()),
8754            version: Some("0.98.1".to_owned()),
8755            channel: None,
8756            match_criteria: Some("Moniker: powertoys".to_owned()),
8757        };
8758        let tag_match = SearchMatch {
8759            source_name: "winget".to_owned(),
8760            source_kind: SourceKind::PreIndexed,
8761            id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
8762            name: "QR Codes for Command Palette".to_owned(),
8763            moniker: None,
8764            version: Some("0.4.0.0".to_owned()),
8765            channel: None,
8766            match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
8767        };
8768
8769        assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
8770    }
8771
8772    #[test]
8773    fn select_best_text_match_prefers_exact_tag_value() {
8774        let values = vec![
8775            "microsoft-powertoys".to_owned(),
8776            "powertoys-run".to_owned(),
8777            "powertoys".to_owned(),
8778        ];
8779
8780        assert_eq!(
8781            select_best_text_match(values, "PowerToys", false).as_deref(),
8782            Some("powertoys")
8783        );
8784    }
8785
8786    #[test]
8787    fn search_match_criteria_is_blank_for_name_match() {
8788        let query = PackageQuery {
8789            query: Some("PowerToys".to_owned()),
8790            ..Default::default()
8791        };
8792
8793        let result = infer_match_criteria(
8794            "Microsoft.PowerToys",
8795            "PowerToys",
8796            Some("powertoys"),
8797            &query,
8798            SearchSemantics::Many,
8799            |_| Ok(Some("powertoys".to_owned())),
8800            |_| Ok(None),
8801        )
8802        .expect("match criteria");
8803
8804        assert_eq!(result, None);
8805    }
8806
8807    #[test]
8808    fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
8809        let query = PackageQuery {
8810            query: Some("PowerToys".to_owned()),
8811            ..Default::default()
8812        };
8813        let unknown_version = SearchMatch {
8814            source_name: "msstore".to_owned(),
8815            source_kind: SourceKind::Rest,
8816            id: "XP89DCGQ3K6VLD".to_owned(),
8817            name: "Microsoft PowerToys".to_owned(),
8818            moniker: None,
8819            version: Some("Unknown".to_owned()),
8820            channel: None,
8821            match_criteria: None,
8822        };
8823        let tag_match = SearchMatch {
8824            source_name: "winget".to_owned(),
8825            source_kind: SourceKind::PreIndexed,
8826            id: "Riri.QRCodeforCmdPal".to_owned(),
8827            name: "QR Code for CmdPal".to_owned(),
8828            moniker: None,
8829            version: Some("0.0.1.0".to_owned()),
8830            channel: None,
8831            match_criteria: Some("Tag: powertoys".to_owned()),
8832        };
8833
8834        assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
8835    }
8836
8837    #[test]
8838    fn search_source_fetch_results_ignore_small_display_count() {
8839        let query = PackageQuery {
8840            count: Some(5),
8841            ..Default::default()
8842        };
8843
8844        assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
8845        assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
8846    }
8847
8848    #[test]
8849    fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
8850        let query = PackageQuery {
8851            query: Some("PowerToys".to_owned()),
8852            ..Default::default()
8853        };
8854        let prefix_name = SearchMatch {
8855            source_name: "winget".to_owned(),
8856            source_kind: SourceKind::PreIndexed,
8857            id: "advaith.CurrencyConverterPowerToys".to_owned(),
8858            name: "PowerToys-Run-Currency-Converter".to_owned(),
8859            moniker: None,
8860            version: Some("1.5.4".to_owned()),
8861            channel: None,
8862            match_criteria: None,
8863        };
8864        let exact_tag = SearchMatch {
8865            source_name: "winget".to_owned(),
8866            source_kind: SourceKind::PreIndexed,
8867            id: "Riri.QRCodeforCmdPal".to_owned(),
8868            name: "QR Code for CmdPal".to_owned(),
8869            moniker: None,
8870            version: Some("0.0.1.0".to_owned()),
8871            channel: None,
8872            match_criteria: Some("Tag: powertoys".to_owned()),
8873        };
8874
8875        assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
8876    }
8877
8878    #[test]
8879    fn list_sort_prefers_main_package_then_sparse_app() {
8880        let main = InstalledPackage {
8881            name: "PowerToys (Preview) x64".to_owned(),
8882            local_id: r"ARP\User\X64\PowerToys".to_owned(),
8883            installed_version: "0.98.1".to_owned(),
8884            publisher: None,
8885            scope: Some("User".to_owned()),
8886            installer_category: Some("exe".to_owned()),
8887            install_location: None,
8888            package_family_names: Vec::new(),
8889            product_codes: Vec::new(),
8890            upgrade_codes: Vec::new(),
8891            correlated: None,
8892        };
8893        let sparse = InstalledPackage {
8894            name: "PowerToys.SparseApp".to_owned(),
8895            local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
8896            installed_version: "0.98.1.0".to_owned(),
8897            publisher: None,
8898            scope: Some("User".to_owned()),
8899            installer_category: Some("msix".to_owned()),
8900            install_location: None,
8901            package_family_names: Vec::new(),
8902            product_codes: Vec::new(),
8903            upgrade_codes: Vec::new(),
8904            correlated: None,
8905        };
8906        let extension = InstalledPackage {
8907            name: "PowerToys FileLocksmith Context Menu".to_owned(),
8908            local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
8909            installed_version: "0.98.1.0".to_owned(),
8910            publisher: None,
8911            scope: Some("User".to_owned()),
8912            installer_category: Some("msix".to_owned()),
8913            install_location: None,
8914            package_family_names: Vec::new(),
8915            product_codes: Vec::new(),
8916            upgrade_codes: Vec::new(),
8917            correlated: None,
8918        };
8919
8920        assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
8921        assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
8922    }
8923
8924    #[test]
8925    #[cfg(windows)]
8926    fn arp_subkey_name_extracts_registry_subkey() {
8927        assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
8928        assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
8929    }
8930
8931    #[test]
8932    #[cfg(windows)]
8933    fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
8934        let installed = ListMatch {
8935            name: "ShareX".to_owned(),
8936            id: "ShareX.ShareX".to_owned(),
8937            local_id: r"ARP\Machine\X64\ShareX".to_owned(),
8938            installed_version: "19.0.2".to_owned(),
8939            available_version: None,
8940            source_name: Some("winget".to_owned()),
8941            publisher: Some("ShareX Team".to_owned()),
8942            scope: Some("Machine".to_owned()),
8943            installer_category: Some("exe".to_owned()),
8944            install_location: Some(r"C:\Program Files\ShareX".to_owned()),
8945            package_family_names: Vec::new(),
8946            product_codes: Vec::new(),
8947            upgrade_codes: Vec::new(),
8948        };
8949
8950        assert!(registry_entry_matches_installed_package(
8951            "ShareX", "ShareX", None, &installed
8952        ));
8953        assert!(!registry_entry_matches_installed_package(
8954            "ShareX.ShareX",
8955            "ShareX.ShareX",
8956            None,
8957            &installed
8958        ));
8959    }
8960
8961    #[test]
8962    #[cfg(windows)]
8963    fn build_uninstall_command_only_appends_silent_flag_when_needed() {
8964        assert_eq!(
8965            r#""C:\Program Files\ShareX\unins000.exe" /S"#,
8966            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
8967        );
8968        assert_eq!(
8969            r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
8970            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
8971        );
8972        assert_eq!(
8973            r#""C:\Program Files\ShareX\unins000.exe""#,
8974            build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
8975        );
8976        assert_eq!(
8977            "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
8978            build_uninstall_command(
8979                "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
8980                true,
8981                false,
8982            )
8983        );
8984    }
8985
8986    #[test]
8987    #[cfg(windows)]
8988    fn build_winget_uninstall_arguments_prefers_correlated_identity() {
8989        let installed = ListMatch {
8990            name: "lazygit".to_owned(),
8991            id: "JesseDuffield.lazygit".to_owned(),
8992            local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
8993            installed_version: "0.61.1".to_owned(),
8994            available_version: None,
8995            source_name: Some("winget".to_owned()),
8996            publisher: Some("Jesse Duffield".to_owned()),
8997            scope: Some("User".to_owned()),
8998            installer_category: Some("exe".to_owned()),
8999            install_location: Some(
9000                r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
9001            ),
9002            package_family_names: Vec::new(),
9003            product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
9004            upgrade_codes: Vec::new(),
9005        };
9006        let mut request = UninstallRequest::new(PackageQuery {
9007            id: Some("JesseDuffield.lazygit".to_owned()),
9008            install_scope: Some("user".to_owned()),
9009            ..PackageQuery::default()
9010        });
9011        request.mode = InstallerMode::Silent;
9012
9013        assert_eq!(
9014            Some(vec![
9015                "uninstall".to_owned(),
9016                "--id".to_owned(),
9017                "JesseDuffield.lazygit".to_owned(),
9018                "--exact".to_owned(),
9019                "--disable-interactivity".to_owned(),
9020                "--source".to_owned(),
9021                "winget".to_owned(),
9022                "--scope".to_owned(),
9023                "user".to_owned(),
9024                "--silent".to_owned(),
9025            ]),
9026            build_winget_uninstall_arguments(&installed, &request)
9027        );
9028
9029        assert_eq!(
9030            Some(vec![
9031                "uninstall".to_owned(),
9032                "--id".to_owned(),
9033                "JesseDuffield.lazygit".to_owned(),
9034                "--exact".to_owned(),
9035                "--disable-interactivity".to_owned(),
9036                "--source".to_owned(),
9037                "winget".to_owned(),
9038                "--silent".to_owned(),
9039            ]),
9040            build_winget_uninstall_arguments_with_scope(&installed, &request, false)
9041        );
9042    }
9043}