winbrew_app\operations\install/
state.rs

1//! Database and filesystem state helpers for installation.
2//!
3//! This module is responsible for the persistence side of the install flow.
4//! It validates whether a package can be installed, removes stale failed state,
5//! records the package as installing, and flips the status to either installed
6//! or failed once the engine phase completes.
7//!
8//! Keeping these transitions isolated makes the outer install orchestration
9//! easier to reason about and gives rollback a single place to update package
10//! status.
11
12use std::path::Path;
13use thiserror::Error;
14
15use crate::core::fs::cleanup_path;
16use crate::core::now;
17use crate::database;
18use winbrew_models::domains::install::EngineKind;
19use winbrew_models::domains::install::InstallerType;
20use winbrew_models::domains::installed::{InstalledPackage, PackageStatus};
21use winbrew_models::domains::shared::DeploymentKind;
22
23/// Errors raised while preparing or updating install state.
24#[derive(Debug, Error)]
25pub enum InstallStateError {
26    #[error("failed to read install state for '{name}'")]
27    LookupFailed {
28        name: String,
29        #[source]
30        source: anyhow::Error,
31    },
32
33    #[error("package '{name}' is already installed")]
34    AlreadyInstalled { name: String },
35
36    #[error("package '{name}' is already being installed")]
37    AlreadyInstalling { name: String },
38
39    #[error("package '{name}' is currently updating")]
40    CurrentlyUpdating { name: String },
41
42    #[error("command '{command}' is already exposed by package '{package}'")]
43    CommandAlreadyExposed { command: String, package: String },
44
45    #[error("failed to delete failed install record for '{name}'")]
46    DeleteFailed {
47        name: String,
48        #[source]
49        source: anyhow::Error,
50    },
51
52    #[error("failed to clean up install directory at {path}")]
53    CleanupFailed {
54        path: String,
55        #[source]
56        source: anyhow::Error,
57    },
58
59    #[error("failed to update install state during {operation}")]
60    DatabaseOperationFailed {
61        operation: &'static str,
62        #[source]
63        source: anyhow::Error,
64    },
65}
66
67/// Convenience result type for install-state operations.
68pub type Result<T> = std::result::Result<T, InstallStateError>;
69
70/// A command already owned by a different package.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct CommandConflict {
73    pub command: String,
74    pub package: String,
75}
76
77/// The current state of an install target before any mutation occurs.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum InstallTargetState {
80    Ready,
81    AlreadyInstalled,
82    AlreadyInstalling,
83    CurrentlyUpdating,
84    Failed,
85    Orphaned,
86}
87
88/// Read-only inspection of the current install target.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct InstallTargetInspection {
91    pub state: InstallTargetState,
92    pub command_conflicts: Vec<CommandConflict>,
93}
94
95impl InstallTargetInspection {
96    fn ready(state: InstallTargetState, command_conflicts: Vec<CommandConflict>) -> Self {
97        Self {
98            state,
99            command_conflicts,
100        }
101    }
102
103    pub fn has_conflicts(&self) -> bool {
104        !self.command_conflicts.is_empty()
105    }
106}
107
108/// Validate the target install path and clear stale failed state if present.
109///
110/// This function enforces the database-level install preconditions before any
111/// download work begins. It rejects packages that are already installed,
112/// already installing, or currently updating. If the previous attempt failed,
113/// the stale package row is removed and the install directory is cleaned so the
114/// next attempt starts from a known-good state.
115pub fn prepare_install_target(
116    conn: &crate::database::DbConnection,
117    name: &str,
118    install_dir: &Path,
119) -> Result<()> {
120    prepare_install_target_with_commands(conn, name, install_dir, None)
121}
122
123/// Validate the target install path and clear stale failed state if present.
124///
125/// This variant also checks the command registry so command conflicts fail
126/// before the installer payload is downloaded.
127pub fn prepare_install_target_with_commands(
128    conn: &crate::database::DbConnection,
129    name: &str,
130    install_dir: &Path,
131    package_commands: Option<&str>,
132) -> Result<()> {
133    let inspection =
134        inspect_install_target_with_commands(conn, name, install_dir, package_commands)?;
135
136    match inspection.state {
137        InstallTargetState::Ready => {}
138        InstallTargetState::AlreadyInstalled => {
139            return Err(InstallStateError::AlreadyInstalled {
140                name: name.to_string(),
141            });
142        }
143        InstallTargetState::AlreadyInstalling => {
144            return Err(InstallStateError::AlreadyInstalling {
145                name: name.to_string(),
146            });
147        }
148        InstallTargetState::CurrentlyUpdating => {
149            return Err(InstallStateError::CurrentlyUpdating {
150                name: name.to_string(),
151            });
152        }
153        InstallTargetState::Failed => {
154            database::delete_package(conn, name).map_err(|source| {
155                InstallStateError::DeleteFailed {
156                    name: name.to_string(),
157                    source,
158                }
159            })?;
160
161            cleanup_path(install_dir).map_err(|source| InstallStateError::CleanupFailed {
162                path: install_dir.to_string_lossy().into_owned(),
163                source: source.into(),
164            })?;
165        }
166        InstallTargetState::Orphaned => {
167            cleanup_path(install_dir).map_err(|source| InstallStateError::CleanupFailed {
168                path: install_dir.to_string_lossy().into_owned(),
169                source: source.into(),
170            })?;
171        }
172    }
173
174    if let Some(conflict) = inspection.command_conflicts.first() {
175        return Err(InstallStateError::CommandAlreadyExposed {
176            command: conflict.command.clone(),
177            package: conflict.package.clone(),
178        });
179    }
180
181    Ok(())
182}
183
184/// Inspect the target install state without mutating the database or disk.
185pub fn inspect_install_target_with_commands(
186    conn: &crate::database::DbConnection,
187    name: &str,
188    install_dir: &Path,
189    package_commands: Option<&str>,
190) -> Result<InstallTargetInspection> {
191    let state = inspect_install_target_state(conn, name, install_dir)?;
192    let command_conflicts = inspect_command_conflicts(conn, name, package_commands)?;
193
194    Ok(InstallTargetInspection::ready(state, command_conflicts))
195}
196
197/// Insert a package record marked as installing.
198///
199/// The record captures the package metadata and provisional install directory so
200/// the database reflects that work is in progress before the payload download
201/// starts.
202pub fn mark_installing(
203    conn: &crate::database::DbConnection,
204    name: impl Into<String>,
205    version: impl Into<String>,
206    kind: InstallerType,
207    deployment_kind: DeploymentKind,
208    engine_kind: EngineKind,
209    install_dir: &Path,
210) -> Result<()> {
211    let package = installing_package(
212        name,
213        version,
214        kind,
215        deployment_kind,
216        engine_kind,
217        install_dir,
218    );
219    database::insert_package(conn, &package).map_err(|source| {
220        InstallStateError::DatabaseOperationFailed {
221            operation: "marking package as installing",
222            source,
223        }
224    })
225}
226
227/// Mark a package as failed.
228///
229/// The outer install flow uses this during rollback to preserve the failure
230/// state in the local database after partial installation has been cleaned up.
231pub fn mark_failed(conn: &crate::database::DbConnection, name: &str) -> Result<()> {
232    database::update_status(conn, name, PackageStatus::Failed).map_err(|source| {
233        InstallStateError::DatabaseOperationFailed {
234            operation: "marking package as failed",
235            source,
236        }
237    })
238}
239
240pub fn update_installing_identity(
241    conn: &crate::database::DbConnection,
242    name: &str,
243    kind: InstallerType,
244    deployment_kind: DeploymentKind,
245    engine_kind: EngineKind,
246) -> Result<()> {
247    database::update_installing_identity(conn, name, kind, deployment_kind, engine_kind).map_err(
248        |source| InstallStateError::DatabaseOperationFailed {
249            operation: "updating install classification",
250            source,
251        },
252    )
253}
254
255fn installing_package(
256    name: impl Into<String>,
257    version: impl Into<String>,
258    kind: InstallerType,
259    deployment_kind: DeploymentKind,
260    engine_kind: EngineKind,
261    install_dir: &Path,
262) -> InstalledPackage {
263    InstalledPackage {
264        name: name.into(),
265        version: version.into(),
266        kind,
267        deployment_kind,
268        engine_kind,
269        engine_metadata: None,
270        install_dir: install_dir.to_string_lossy().into_owned(),
271        dependencies: Vec::new(),
272        status: PackageStatus::Installing,
273        // Provisional value for in-progress installs; database::commit_install overwrites it.
274        installed_at: now(),
275    }
276}
277
278fn inspect_install_target_state(
279    conn: &crate::database::DbConnection,
280    name: &str,
281    install_dir: &Path,
282) -> Result<InstallTargetState> {
283    if let Some(existing) =
284        database::get_package(conn, name).map_err(|source| InstallStateError::LookupFailed {
285            name: name.to_string(),
286            source,
287        })?
288    {
289        return Ok(match existing.status {
290            PackageStatus::Ok => InstallTargetState::AlreadyInstalled,
291            PackageStatus::Installing => InstallTargetState::AlreadyInstalling,
292            PackageStatus::Updating => InstallTargetState::CurrentlyUpdating,
293            PackageStatus::Failed => InstallTargetState::Failed,
294        });
295    }
296
297    if install_dir.exists() {
298        return Ok(InstallTargetState::Orphaned);
299    }
300
301    Ok(InstallTargetState::Ready)
302}
303
304fn inspect_command_conflicts(
305    conn: &crate::database::DbConnection,
306    name: &str,
307    package_commands: Option<&str>,
308) -> Result<Vec<CommandConflict>> {
309    let command_names = database::parse_command_names(package_commands).map_err(|source| {
310        InstallStateError::DatabaseOperationFailed {
311            operation: "parsing exposed commands",
312            source,
313        }
314    })?;
315
316    let owners = database::find_command_owners(conn, &command_names).map_err(|source| {
317        InstallStateError::DatabaseOperationFailed {
318            operation: "reading command registry",
319            source,
320        }
321    })?;
322
323    let mut conflicts = Vec::new();
324    for command in command_names {
325        if let Some(owner) = owners.get(&command)
326            && owner != name
327        {
328            conflicts.push(CommandConflict {
329                command,
330                package: owner.clone(),
331            });
332        }
333    }
334
335    Ok(conflicts)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::prepare_install_target_with_commands;
341    use crate::core::paths::resolved_paths;
342    use crate::database;
343    use std::path::Path;
344    use tempfile::tempdir;
345    use winbrew_models::domains::install::{
346        EngineInstallReceipt, EngineKind, EngineMetadata, InstallScope, InstallerType,
347    };
348    use winbrew_models::domains::installed::{InstalledPackage, PackageStatus};
349    use winbrew_models::domains::inventory::{
350        MsiComponentRecord, MsiFileRecord, MsiInventoryReceipt, MsiInventorySnapshot,
351        MsiRegistryRecord, MsiShortcutRecord,
352    };
353
354    fn init_storage(root: &Path) {
355        let packages = root.join("packages").to_string_lossy().into_owned();
356        let data = root.join("data").to_string_lossy().into_owned();
357        let logs = root.join("logs").to_string_lossy().into_owned();
358        let cache = root.join("cache").to_string_lossy().into_owned();
359        let paths = resolved_paths(root, &packages, &data, &logs, &cache);
360
361        database::init(&paths).expect("database should initialize");
362    }
363
364    fn sample_package(name: &str, install_dir: &str) -> InstalledPackage {
365        InstalledPackage {
366            name: name.to_string(),
367            version: "1.0.0".to_string(),
368            kind: InstallerType::Msi,
369            deployment_kind: InstallerType::Msi.deployment_kind(),
370            engine_kind: EngineKind::Msi,
371            engine_metadata: None,
372            install_dir: install_dir.to_string(),
373            dependencies: Vec::new(),
374            status: PackageStatus::Installing,
375            installed_at: "2026-04-12T00:00:00Z".to_string(),
376        }
377    }
378
379    fn sample_snapshot(name: &str, install_dir: &str) -> MsiInventorySnapshot {
380        let normalized_install_dir = install_dir.replace('\\', "/").to_ascii_lowercase();
381
382        MsiInventorySnapshot {
383            receipt: MsiInventoryReceipt {
384                package_name: name.to_string(),
385                product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
386                upgrade_code: Some("{22222222-2222-2222-2222-222222222222}".to_string()),
387                scope: InstallScope::Installed,
388            },
389            files: vec![MsiFileRecord {
390                package_name: name.to_string(),
391                path: format!("{install_dir}/bin/demo.exe"),
392                normalized_path: format!("{normalized_install_dir}/bin/demo.exe"),
393                hash_algorithm: None,
394                hash_hex: None,
395                is_config_file: false,
396            }],
397            registry_entries: vec![MsiRegistryRecord {
398                package_name: name.to_string(),
399                hive: "HKLM".to_string(),
400                key_path: "Software\\Demo".to_string(),
401                normalized_key_path: "software\\demo".to_string(),
402                value_name: "InstallPath".to_string(),
403                value_data: Some(install_dir.to_string()),
404                previous_value: None,
405            }],
406            shortcuts: vec![MsiShortcutRecord {
407                package_name: name.to_string(),
408                path: format!("{install_dir}/Desktop/Demo.lnk"),
409                normalized_path: format!("{normalized_install_dir}/desktop/demo.lnk"),
410                target_path: Some(format!("{install_dir}/bin/demo.exe")),
411                normalized_target_path: Some(format!("{normalized_install_dir}/bin/demo.exe")),
412            }],
413            components: vec![MsiComponentRecord {
414                package_name: name.to_string(),
415                component_id: "COMPONENT-DEMO".to_string(),
416                path: Some(format!("{install_dir}/bin/demo.exe")),
417                normalized_path: Some(format!("{normalized_install_dir}/bin/demo.exe")),
418            }],
419        }
420    }
421
422    #[test]
423    fn commit_install_persists_msi_snapshot_transactionally() {
424        let root = tempdir().expect("temp root");
425        init_storage(root.path());
426
427        let package_name = "demo";
428        let install_dir = "C:/Tools/Actual";
429
430        let conn = database::get_conn().expect("database connection should open");
431        database::insert_package(&conn, &sample_package(package_name, "C:/Tools/Old"))
432            .expect("insert package");
433
434        let mut receipt = EngineInstallReceipt::new(
435            EngineKind::Msi,
436            install_dir.to_string(),
437            Some(EngineMetadata::Msi {
438                product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
439                upgrade_code: Some("{22222222-2222-2222-2222-222222222222}".to_string()),
440                scope: InstallScope::Installed,
441                registry_keys: vec!["HKLM\\Software\\Demo".to_string()],
442                shortcuts: vec!["C:/Users/Public/Desktop/Demo.lnk".to_string()],
443            }),
444        );
445        receipt.msi_inventory_snapshot = Some(sample_snapshot(package_name, install_dir));
446
447        let mut conn = conn;
448        database::commit_install(&mut conn, package_name, &receipt)
449            .expect("commit package install");
450
451        let package = database::get_package(&conn, package_name)
452            .expect("read package")
453            .expect("package should exist");
454
455        assert_eq!(package.status, PackageStatus::Ok);
456        assert_eq!(package.install_dir, install_dir);
457
458        let file_owners =
459            database::find_packages_by_normalized_path(&conn, "c:/tools/actual/bin/demo.exe")
460                .expect("lookup file owners");
461        assert_eq!(file_owners, vec![package_name.to_string()]);
462
463        let registry_owners =
464            database::find_packages_by_normalized_registry_key_path(&conn, "software\\demo")
465                .expect("lookup registry owners");
466        assert_eq!(registry_owners, vec![package_name.to_string()]);
467    }
468
469    #[test]
470    fn prepare_install_target_rejects_conflicting_command_exposure() {
471        let root = tempdir().expect("temp root");
472        init_storage(root.path());
473
474        let conn = database::get_conn().expect("database connection should open");
475        let owner = sample_package("Contoso.CommandOwner", "C:/Tools/Owner");
476        database::insert_package(&conn, &owner).expect("insert owner package");
477        database::sync_package_commands(&conn, &owner.name, Some(r#"["grep"]"#))
478            .expect("seed command registry");
479
480        let install_dir = root.path().join("packages").join("Contoso.CommandConflict");
481        let err = prepare_install_target_with_commands(
482            &conn,
483            "Contoso.CommandConflict",
484            &install_dir,
485            Some(r#"["grep"]"#),
486        )
487        .expect_err("conflicting command should be rejected");
488
489        assert!(err.to_string().contains("already exposed by package"));
490    }
491}