winbrew_database\catalog/
installers.rs

1use anyhow::{Context, Result};
2use rusqlite::{Connection, params};
3
4use super::row::conversion_err;
5use crate::models::catalog::installer_type::CatalogInstallerType;
6use crate::models::catalog::package::CatalogInstaller;
7use crate::models::catalog::raw::RawCatalogInstaller;
8use crate::models::shared::HashAlgorithm;
9
10/// Returns all catalog installers for the given `package_id`.
11///
12/// Results are ordered by the canonical installer identity columns so the
13/// downstream installer selector sees deterministic ties.
14///
15/// # Errors
16///
17/// Returns an error if SQLite query execution or row conversion fails.
18pub(crate) fn get_installers(conn: &Connection, package_id: &str) -> Result<Vec<CatalogInstaller>> {
19    let mut stmt = conn.prepare(
20        "SELECT package_id, url, hash, hash_algorithm, installer_type, installer_switches, platform, commands, protocols, file_extensions, capabilities, scope, arch, kind, nested_kind
21         FROM catalog_installers
22         WHERE package_id = ?1
23            ORDER BY url ASC, hash ASC, hash_algorithm ASC, installer_type ASC, installer_switches ASC, scope ASC, arch ASC, kind ASC, nested_kind ASC",
24    )?;
25
26    stmt.query_map(params![package_id], row_to_installer)?
27        .collect::<std::result::Result<Vec<_>, _>>()
28        .context("failed to read catalog installer")
29}
30
31fn row_to_installer(row: &rusqlite::Row) -> rusqlite::Result<CatalogInstaller> {
32    // Raw catalog rows normalize a missing checksum to an empty string.
33    let hash = row.get::<_, Option<String>>("hash")?.unwrap_or_default();
34
35    let raw = RawCatalogInstaller {
36        package_id: row.get::<_, String>("package_id")?,
37        url: row.get("url")?,
38        hash,
39        hash_algorithm: parse_text::<HashAlgorithm>(row.get::<_, String>("hash_algorithm")?)?,
40        installer_type: parse_text::<CatalogInstallerType>(
41            row.get::<_, String>("installer_type")?,
42        )?,
43        installer_switches: row.get("installer_switches")?,
44        platform: row.get("platform")?,
45        commands: row.get("commands")?,
46        protocols: row.get("protocols")?,
47        file_extensions: row.get("file_extensions")?,
48        capabilities: row.get("capabilities")?,
49        scope: row.get("scope")?,
50        arch: row.get("arch")?,
51        kind: row.get("kind")?,
52        nested_kind: row.get("nested_kind")?,
53    };
54
55    CatalogInstaller::try_from(raw).map_err(conversion_err)
56}
57
58fn parse_text<T>(value: String) -> rusqlite::Result<T>
59where
60    T: std::str::FromStr,
61    T::Err: std::error::Error + Send + Sync + 'static,
62{
63    value.parse::<T>().map_err(conversion_err)
64}
65
66#[cfg(test)]
67mod tests {
68    use super::get_installers;
69    use crate::models::catalog::CatalogInstallerType;
70    use crate::models::install::installer::InstallerType;
71    use crate::models::shared::HashAlgorithm;
72    use rusqlite::{Connection, params};
73
74    const CATALOG_SCHEMA: &str = include_str!("../../../../infra/parser/schema/catalog.sql");
75
76    fn insert_catalog_package(conn: &Connection) {
77        conn.execute(
78            "INSERT INTO catalog_packages (id, name, version, source, source_id) VALUES (?1, ?2, ?3, ?4, ?5)",
79            params![
80                "winget/Contoso.App",
81                "Contoso App",
82                "1.2.3",
83                "winget",
84                "Contoso.App",
85            ],
86        )
87        .expect("seed catalog package");
88    }
89
90    fn open_test_db() -> Connection {
91        let conn = Connection::open_in_memory().expect("open in-memory database");
92        conn.execute_batch(CATALOG_SCHEMA)
93            .expect("load catalog schema");
94        insert_catalog_package(&conn);
95        conn
96    }
97
98    #[test]
99    fn get_installers_reads_nested_kind_when_present() {
100        let conn = open_test_db();
101
102        conn.execute(
103            r#"
104            INSERT INTO catalog_installers (package_id, url, hash, hash_algorithm, installer_type, installer_switches, arch, kind, nested_kind)
105            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
106            "#,
107            params![
108                "winget/Contoso.App",
109                "https://example.test/app-one.zip",
110                "sha256:deadbeef",
111                "sha256",
112                "zip",
113                None::<String>,
114                "x64",
115                "zip",
116                "portable",
117            ],
118        )
119        .expect("insert portable installer");
120
121        conn.execute(
122            r#"
123            INSERT INTO catalog_installers (package_id, url, hash, hash_algorithm, installer_type, installer_switches, arch, kind, nested_kind)
124            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
125            "#,
126            params![
127                "winget/Contoso.App",
128                "https://example.test/app-two.zip",
129                "sha256:deadbeef",
130                "sha256",
131                "zip",
132                None::<String>,
133                "x64",
134                "zip",
135                "msi",
136            ],
137        )
138        .expect("insert msi installer");
139
140        let installers = get_installers(&conn, "winget/Contoso.App")
141            .expect("catalog installers should load with nested kind");
142
143        assert_eq!(installers.len(), 2);
144        assert_eq!(installers[0].nested_kind, Some(InstallerType::Portable));
145        assert_eq!(installers[1].nested_kind, Some(InstallerType::Msi));
146        assert_eq!(installers[0].hash_algorithm, HashAlgorithm::Sha256);
147        assert_eq!(installers[1].hash_algorithm, HashAlgorithm::Sha256);
148        assert_eq!(installers[0].installer_type, CatalogInstallerType::Zip);
149        assert_eq!(installers[1].installer_type, CatalogInstallerType::Zip);
150    }
151
152    #[test]
153    fn get_installers_reads_null_hash_as_empty_string() {
154        let conn = open_test_db();
155
156        conn.execute(
157            r#"
158            INSERT INTO catalog_installers (package_id, url, hash, hash_algorithm, installer_type, installer_switches, arch, kind, nested_kind)
159            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
160            "#,
161            params![
162                "winget/Contoso.App",
163                "https://example.test/app.exe",
164                None::<String>,
165                "sha256",
166                "exe",
167                None::<String>,
168                "x64",
169                "exe",
170                None::<String>,
171            ],
172        )
173        .expect("insert checksumless installer");
174
175        let installers = get_installers(&conn, "winget/Contoso.App")
176            .expect("catalog installers should load with null hash");
177
178        assert_eq!(installers.len(), 1);
179        assert!(installers[0].hash.is_empty());
180        assert_eq!(installers[0].kind, InstallerType::Exe);
181    }
182}