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