winbrew_database/
installed_packages.rs

1use anyhow::{Context, Result, bail};
2use rusqlite::{Connection, Error as SqlError, OptionalExtension, params, types::Type};
3use thiserror::Error;
4
5use crate::core::now;
6use crate::models::command_resolution::ResolverResult;
7use crate::models::install::engine::{EngineInstallReceipt, EngineKind, EngineMetadata};
8use crate::models::install::installed::{InstalledPackage, PackageStatus};
9use crate::models::install::installer::InstallerType;
10use crate::models::shared::DeploymentKind;
11
12#[derive(Debug, Error)]
13#[error("package '{name}' not found")]
14pub struct PackageNotFoundError {
15    pub name: String,
16}
17
18pub fn insert_package(conn: &Connection, pkg: &InstalledPackage) -> Result<()> {
19    let deps =
20        serde_json::to_string(&pkg.dependencies).context("failed to serialize dependencies")?;
21    let engine_metadata = pkg
22        .engine_metadata
23        .as_ref()
24        .map(serde_json::to_string)
25        .transpose()
26        .context("failed to serialize engine metadata")?;
27
28    conn.execute(
29        "INSERT INTO installed_packages
30         (name, version, kind, deployment_kind, engine_kind, engine_metadata, install_dir, dependencies, status, installed_at)
31         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
32        params![
33            pkg.name,
34            pkg.version,
35            pkg.kind.to_string(),
36            pkg.deployment_kind.to_string(),
37            pkg.engine_kind.to_string(),
38            engine_metadata,
39            pkg.install_dir,
40            deps,
41            pkg.status.as_str(),
42            pkg.installed_at,
43        ],
44    )
45    .context("failed to insert package")?;
46
47    Ok(())
48}
49
50pub fn update_status(conn: &Connection, name: &str, status: PackageStatus) -> Result<()> {
51    let affected = conn
52        .execute(
53            "UPDATE installed_packages SET status = ?1 WHERE name = ?2",
54            params![status.as_str(), name],
55        )
56        .context("failed to update status")?;
57
58    if affected == 0 {
59        return Err(PackageNotFoundError {
60            name: name.to_string(),
61        }
62        .into());
63    }
64
65    Ok(())
66}
67
68pub fn update_installing_identity(
69    conn: &Connection,
70    name: &str,
71    kind: InstallerType,
72    deployment_kind: DeploymentKind,
73    engine_kind: EngineKind,
74) -> Result<()> {
75    conn.execute(
76        "UPDATE installed_packages
77            SET kind = ?1,
78                deployment_kind = ?2,
79                engine_kind = ?3
80          WHERE name = ?4",
81        params![
82            kind.to_string(),
83            deployment_kind.to_string(),
84            engine_kind.to_string(),
85            name,
86        ],
87    )
88    .context("failed to update install classification")?;
89
90    Ok(())
91}
92
93pub fn update_status_and_engine_metadata(
94    conn: &Connection,
95    name: &str,
96    status: PackageStatus,
97    engine_metadata: Option<&EngineMetadata>,
98    install_dir: &str,
99    installed_at: &str,
100) -> Result<()> {
101    let engine_metadata = engine_metadata
102        .map(serde_json::to_string)
103        .transpose()
104        .context("failed to serialize engine metadata")?;
105
106    let affected = conn
107        .execute(
108            "UPDATE installed_packages
109                SET status = ?1,
110                    engine_metadata = ?2,
111                                        install_dir = ?3,
112                                        installed_at = ?4
113                            WHERE name = ?5",
114            params![
115                status.as_str(),
116                engine_metadata,
117                install_dir,
118                installed_at,
119                name
120            ],
121        )
122        .context("failed to update status and engine metadata")?;
123
124    if affected == 0 {
125        return Err(PackageNotFoundError {
126            name: name.to_string(),
127        }
128        .into());
129    }
130
131    Ok(())
132}
133
134pub fn commit_install(
135    conn: &mut crate::DbConnection,
136    name: &str,
137    engine_receipt: &EngineInstallReceipt,
138) -> Result<()> {
139    commit_install_with_commands(conn, name, engine_receipt, None)
140}
141
142pub fn commit_install_with_commands(
143    conn: &mut crate::DbConnection,
144    name: &str,
145    engine_receipt: &EngineInstallReceipt,
146    package_commands: Option<&str>,
147) -> Result<()> {
148    let installed_at = now();
149    let tx = conn
150        .transaction()
151        .context("failed to start install commit transaction")?;
152
153    update_status_and_engine_metadata(
154        &tx,
155        name,
156        PackageStatus::Ok,
157        engine_receipt.engine_metadata.as_ref(),
158        engine_receipt.install_dir.as_str(),
159        &installed_at,
160    )?;
161
162    crate::sync_package_commands(&tx, name, package_commands)?;
163
164    if let Some(snapshot) = engine_receipt.msi_inventory_snapshot.as_ref() {
165        crate::apply_snapshot(&tx, snapshot)?;
166    }
167
168    tx.commit().context("failed to commit install state")?;
169
170    Ok(())
171}
172
173pub fn replay_committed_journal(
174    conn: &mut Connection,
175    journal: &crate::CommittedJournalPackage,
176) -> Result<()> {
177    let tx = conn
178        .transaction()
179        .context("failed to start journal replay transaction")?;
180
181    let _ = delete_package(&tx, &journal.package.name)?;
182    insert_package(&tx, &journal.package)?;
183
184    let Some(command_resolution) = journal.command_resolution.as_ref() else {
185        bail!(
186            "committed journal at {} is missing command resolution metadata",
187            journal.journal_path.display()
188        );
189    };
190
191    let commands_json = match command_resolution {
192        ResolverResult::Resolved { commands, .. } => {
193            Some(serde_json::to_string(commands).context("failed to serialize commands")?)
194        }
195        ResolverResult::Unresolved { .. } => None,
196    };
197
198    crate::sync_package_commands(&tx, &journal.package.name, commands_json.as_deref())?;
199
200    tx.commit()
201        .context("failed to commit journal replay transaction")?;
202
203    Ok(())
204}
205
206pub fn get_package(conn: &Connection, name: &str) -> Result<Option<InstalledPackage>> {
207    let mut stmt = conn.prepare(
208        "SELECT name, version, kind, deployment_kind, engine_kind, engine_metadata, install_dir, dependencies, status, installed_at
209            FROM installed_packages WHERE name = ?1",
210    )?;
211
212    stmt.query_row(params![name], row_to_package)
213        .optional()
214        .context("failed to query package")
215}
216
217pub fn list_packages(conn: &Connection) -> Result<Vec<InstalledPackage>> {
218    let mut stmt = conn.prepare(
219        // Returns only packages that completed successfully.
220        "SELECT name, version, kind, deployment_kind, engine_kind, engine_metadata, install_dir, dependencies, status, installed_at
221            FROM installed_packages WHERE status = 'ok'
222         ORDER BY name ASC",
223    )?;
224
225    stmt.query_map([], row_to_package)?
226        .map(|row: rusqlite::Result<InstalledPackage>| row.context("failed to read row"))
227        .collect()
228}
229
230pub fn list_installing_packages(conn: &Connection) -> Result<Vec<InstalledPackage>> {
231    let mut stmt = conn.prepare(
232        "SELECT name, version, kind, deployment_kind, engine_kind, engine_metadata, install_dir, dependencies, status, installed_at
233            FROM installed_packages WHERE status = 'installing'
234         ORDER BY installed_at ASC, name ASC",
235    )?;
236
237    stmt.query_map([], row_to_package)?
238        .map(|row: rusqlite::Result<InstalledPackage>| row.context("failed to read row"))
239        .collect()
240}
241
242pub fn delete_package(conn: &Connection, name: &str) -> Result<bool> {
243    let affected = conn
244        .execute(
245            "DELETE FROM installed_packages WHERE name = ?1",
246            params![name],
247        )
248        .context("failed to delete package")?;
249    Ok(affected > 0)
250}
251
252fn row_to_package(row: &rusqlite::Row) -> std::result::Result<InstalledPackage, SqlError> {
253    const COL_KIND: usize = 2;
254    const COL_DEPLOYMENT_KIND: usize = 3;
255    const COL_ENGINE_KIND: usize = 4;
256    const COL_ENGINE_METADATA: usize = 5;
257    const COL_DEPENDENCIES: usize = 7;
258    const COL_STATUS: usize = 8;
259
260    let dependencies_raw: String = row.get("dependencies")?;
261    let status_raw: String = row.get("status")?;
262    let kind_raw: String = row.get("kind")?;
263    let deployment_kind_raw: String = row.get("deployment_kind")?;
264    let engine_kind_raw: String = row.get("engine_kind")?;
265    let engine_metadata_raw: Option<String> = row.get("engine_metadata")?;
266
267    let dependencies: Vec<String> = serde_json::from_str(&dependencies_raw).map_err(|err| {
268        SqlError::FromSqlConversionFailure(COL_DEPENDENCIES, Type::Text, Box::new(err))
269    })?;
270    let kind = kind_raw
271        .parse::<InstallerType>()
272        .map_err(|err| SqlError::FromSqlConversionFailure(COL_KIND, Type::Text, Box::new(err)))?;
273    let deployment_kind = deployment_kind_raw
274        .parse::<DeploymentKind>()
275        .map_err(|err| {
276            SqlError::FromSqlConversionFailure(COL_DEPLOYMENT_KIND, Type::Text, Box::new(err))
277        })?;
278    let engine_kind = engine_kind_raw.parse::<EngineKind>().map_err(|err| {
279        SqlError::FromSqlConversionFailure(COL_ENGINE_KIND, Type::Text, Box::new(err))
280    })?;
281    let engine_metadata = match engine_metadata_raw {
282        Some(value) => Some(serde_json::from_str(&value).map_err(|err| {
283            SqlError::FromSqlConversionFailure(COL_ENGINE_METADATA, Type::Text, Box::new(err))
284        })?),
285        None => None,
286    };
287    let status = status_raw
288        .parse::<PackageStatus>()
289        .map_err(|err| SqlError::FromSqlConversionFailure(COL_STATUS, Type::Text, Box::new(err)))?;
290
291    Ok(InstalledPackage {
292        name: row.get("name")?,
293        version: row.get("version")?,
294        kind,
295        deployment_kind,
296        engine_kind,
297        engine_metadata,
298        install_dir: row.get("install_dir")?,
299        dependencies,
300        status,
301        installed_at: row.get("installed_at")?,
302    })
303}
304
305#[cfg(test)]
306mod tests {
307    use super::{
308        get_package, insert_package, replay_committed_journal, update_installing_identity,
309        update_status_and_engine_metadata,
310    };
311    use crate::migration;
312    use crate::models::command_resolution::{
313        CommandSource, Confidence, ResolverResult, VersionScope,
314    };
315    use crate::models::install::engine::{EngineKind, EngineMetadata, InstallScope};
316    use crate::models::install::installed::{InstalledPackage, PackageStatus};
317    use crate::models::install::installer::InstallerType;
318    use crate::models::shared::DeploymentKind;
319    use rusqlite::Connection;
320    use std::path::PathBuf;
321
322    fn sample_package(name: &str) -> InstalledPackage {
323        InstalledPackage {
324            name: name.to_string(),
325            version: "1.0.0".to_string(),
326            kind: InstallerType::Msi,
327            deployment_kind: DeploymentKind::Installed,
328            engine_kind: EngineKind::Msi,
329            engine_metadata: Some(EngineMetadata::Msi {
330                product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
331                upgrade_code: None,
332                scope: InstallScope::Installed,
333                registry_keys: Vec::new(),
334                shortcuts: Vec::new(),
335            }),
336            install_dir: "C:/Tools/Old".to_string(),
337            dependencies: Vec::new(),
338            status: PackageStatus::Installing,
339            installed_at: "2026-04-12T00:00:00Z".to_string(),
340        }
341    }
342
343    #[test]
344    fn update_status_and_engine_metadata_overwrites_install_dir() {
345        let conn = Connection::open_in_memory().expect("open in-memory database");
346        migration::migrate(&conn).expect("run migration");
347
348        let package_name = "demo";
349        insert_package(&conn, &sample_package(package_name)).expect("insert package");
350
351        update_status_and_engine_metadata(
352            &conn,
353            package_name,
354            PackageStatus::Ok,
355            Some(&EngineMetadata::Msi {
356                product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
357                upgrade_code: Some("{22222222-2222-2222-2222-222222222222}".to_string()),
358                scope: InstallScope::Installed,
359                registry_keys: vec!["HKLM\\Software\\Demo".to_string()],
360                shortcuts: vec!["C:/Users/Public/Desktop/Demo.lnk".to_string()],
361            }),
362            "C:/Tools/Actual",
363            "2026-04-12T00:10:00Z",
364        )
365        .expect("update package state");
366
367        let package = get_package(&conn, package_name)
368            .expect("read updated package")
369            .expect("package should exist");
370
371        assert_eq!(package.install_dir, "C:/Tools/Actual");
372        assert_eq!(package.status, PackageStatus::Ok);
373        assert_eq!(
374            package.engine_metadata.unwrap(),
375            EngineMetadata::Msi {
376                product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
377                upgrade_code: Some("{22222222-2222-2222-2222-222222222222}".to_string()),
378                scope: InstallScope::Installed,
379                registry_keys: vec!["HKLM\\Software\\Demo".to_string()],
380                shortcuts: vec!["C:/Users/Public/Desktop/Demo.lnk".to_string()],
381            }
382        );
383    }
384
385    #[test]
386    fn replay_committed_journal_replaces_existing_package() {
387        let mut conn = Connection::open_in_memory().expect("open in-memory database");
388        migration::migrate(&conn).expect("run migration");
389
390        let package_name = "demo";
391        insert_package(&conn, &sample_package(package_name)).expect("insert original package");
392
393        let replay_package = InstalledPackage {
394            install_dir: "C:/Tools/Replayed".to_string(),
395            status: PackageStatus::Ok,
396            installed_at: "2026-04-12T01:00:00Z".to_string(),
397            ..sample_package(package_name)
398        };
399
400        let replay = crate::CommittedJournalPackage {
401            journal_path: PathBuf::from("C:/tmp/journal.jsonl"),
402            entries: Vec::new(),
403            package: replay_package,
404            commands: Some(vec!["contoso".to_string()]),
405            bin: Some(vec!["bin/tool.exe".to_string()]),
406            bin_bindings: None,
407            env_add_path: Vec::new(),
408            command_resolution: Some(ResolverResult::Resolved {
409                commands: vec!["contoso".to_string()],
410                confidence: Confidence::High,
411                sources: vec![CommandSource::PackageLevel],
412                version_scope: VersionScope::Specific("1.0.0".to_string()),
413                catalog_fingerprint: "sha256:deadbeef".to_string(),
414            }),
415        };
416
417        replay_committed_journal(&mut conn, &replay).expect("replay committed journal");
418
419        let package = get_package(&conn, package_name)
420            .expect("read replayed package")
421            .expect("package should exist");
422
423        assert_eq!(package.install_dir, "C:/Tools/Replayed");
424        assert_eq!(package.status, PackageStatus::Ok);
425        assert_eq!(package.installed_at, "2026-04-12T01:00:00Z");
426    }
427
428    #[test]
429    fn replay_committed_journal_rejects_missing_command_resolution_metadata() {
430        let mut conn = Connection::open_in_memory().expect("open in-memory database");
431        migration::migrate(&conn).expect("run migration");
432
433        let package = sample_package("Contoso.Replay");
434        insert_package(&conn, &package).expect("insert original package");
435
436        let replay = crate::CommittedJournalPackage {
437            journal_path: PathBuf::from("C:/tmp/journal.jsonl"),
438            entries: Vec::new(),
439            package: InstalledPackage {
440                status: PackageStatus::Ok,
441                install_dir: "C:/Tools/Replayed".to_string(),
442                installed_at: "2026-04-12T01:00:00Z".to_string(),
443                ..package.clone()
444            },
445            commands: Some(vec!["grep".to_string(), "git".to_string()]),
446            bin: None,
447            bin_bindings: None,
448            env_add_path: Vec::new(),
449            command_resolution: None,
450        };
451
452        let err = replay_committed_journal(&mut conn, &replay)
453            .expect_err("legacy journal should be rejected");
454
455        assert!(
456            err.to_string()
457                .contains("missing command resolution metadata")
458        );
459    }
460
461    #[test]
462    fn update_installing_identity_overwrites_routing_fields() {
463        let conn = Connection::open_in_memory().expect("open in-memory database");
464        migration::migrate(&conn).expect("run migration");
465
466        let package_name = "demo";
467        insert_package(&conn, &sample_package(package_name)).expect("insert original package");
468
469        update_installing_identity(
470            &conn,
471            package_name,
472            InstallerType::Portable,
473            DeploymentKind::Portable,
474            EngineKind::Portable,
475        )
476        .expect("update install identity");
477
478        let package = get_package(&conn, package_name)
479            .expect("read updated package")
480            .expect("package should exist");
481
482        assert_eq!(package.kind, InstallerType::Portable);
483        assert_eq!(package.deployment_kind, DeploymentKind::Portable);
484        assert_eq!(package.engine_kind, EngineKind::Portable);
485    }
486}