winbrew_cli\services\bootstrap/
cleanup.rs

1//! Startup-only cleanup for interrupted installs.
2//!
3//! When the CLI starts, it needs to reconcile any package rows that were left
4//! in the `Installing` state by a previous crash or forced termination. That
5//! recovery is performed here, before command dispatch begins, so later command
6//! handlers see a coherent view of the database and filesystem.
7//!
8//! The responsibilities in this module are intentionally narrow:
9//!
10//! - read the current set of installing packages from the database;
11//! - mark each stale package as failed so it no longer looks active;
12//! - remove the package's installation directory if it is still present;
13//! - remove any temp-workspace directories that belong to the interrupted
14//!   install.
15//!
16//! Nothing in this module is meant to be called as a general-purpose repair
17//! API. It is a startup repair mechanism that depends on the database and the
18//! core filesystem cleanup helpers already being available.
19
20use anyhow::Result;
21use std::fs;
22use std::path::{Path, PathBuf};
23use tracing::warn;
24
25use crate::core::fs::cleanup_path;
26use crate::core::temp_workspace::{is_temp_root_for, temp_root_base};
27use crate::database;
28use crate::models::domains::installed::{InstalledPackage, PackageStatus};
29
30/// Find stale `Installing` rows and reconcile them with the filesystem.
31/// The current database connection is obtained from the process-wide storage
32/// layer, which means the caller must have already initialized the database for
33/// the active configuration. Each stale package is handled independently so one
34/// cleanup failure does not prevent the rest of the recovery pass from running.
35pub fn cleanup_stale_installations() -> Result<()> {
36    let conn = database::get_conn()?;
37    let stale_packages = database::list_installing_packages(&conn)?;
38
39    for package in stale_packages {
40        cleanup_stale_installation(&conn, &package);
41    }
42
43    Ok(())
44}
45
46fn cleanup_stale_installation(conn: &crate::database::DbConnection, package: &InstalledPackage) {
47    if let Err(err) = database::update_status(conn, &package.name, PackageStatus::Failed) {
48        warn!(package = %package.name, error = %err, "failed to mark stale install as failed");
49    }
50
51    let install_dir = PathBuf::from(&package.install_dir);
52    cleanup_install_dir(&install_dir, &package.name);
53    cleanup_temp_roots(&package.name, &package.version);
54}
55
56fn cleanup_install_dir(install_dir: &Path, package_name: &str) {
57    if let Err(err) = cleanup_path(install_dir) {
58        warn!(package = package_name, path = %install_dir.display(), error = %err, "failed to clean stale install directory");
59    }
60}
61
62fn cleanup_temp_roots(name: &str, version: &str) {
63    let temp_root_base = temp_root_base();
64
65    if !temp_root_base.exists() {
66        return;
67    }
68
69    let entries = match fs::read_dir(&temp_root_base) {
70        Ok(entries) => entries,
71        Err(err) => {
72            warn!(package = name, error = %err, "failed to enumerate temp directory for stale install cleanup");
73            return;
74        }
75    };
76
77    for entry in entries.flatten() {
78        let path = entry.path();
79        if is_temp_root_for(name, version, &path)
80            && let Err(err) = cleanup_path(&path)
81        {
82            warn!(package = name, path = %path.display(), error = %err, "failed to clean stale temp root");
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::cleanup_stale_installations;
90    use crate::core::temp_workspace;
91    use crate::database;
92    use crate::models::domains::install::InstallerType;
93    use crate::models::domains::installed::{InstalledPackage, PackageStatus};
94    use std::fs;
95    use tempfile::tempdir;
96
97    fn sample_package(
98        name: &str,
99        version: &str,
100        install_dir: &std::path::Path,
101    ) -> InstalledPackage {
102        InstalledPackage {
103            name: name.to_string(),
104            version: version.to_string(),
105            kind: InstallerType::Portable,
106            deployment_kind: InstallerType::Portable.deployment_kind(),
107            engine_kind: InstallerType::Portable.into(),
108            engine_metadata: None,
109            install_dir: install_dir.to_string_lossy().into_owned(),
110            dependencies: Vec::new(),
111            status: PackageStatus::Installing,
112            installed_at: "2026-04-07T00:00:00Z".to_string(),
113        }
114    }
115
116    #[test]
117    fn cleanup_stale_installations_marks_installing_packages_failed_and_cleans_artifacts() {
118        let temp_root = tempdir().expect("temp root");
119        let root = temp_root.path();
120
121        let config = crate::database::Config::load_at(root).expect("config should load");
122        database::init(&config.resolved_paths()).expect("database should initialize");
123
124        let conn = database::get_conn().expect("db connection");
125        let install_dir = root.join("packages").join("Contoso.Stale");
126        fs::create_dir_all(&install_dir).expect("install dir");
127
128        let package = sample_package("Contoso.Stale", "1.0.0", &install_dir);
129        database::insert_package(&conn, &package).expect("insert package");
130
131        let temp_root_path = temp_workspace::temp_root_base().join(format!(
132            "{}test",
133            temp_workspace::temp_root_prefix(&package.name, &package.version)
134        ));
135        fs::create_dir_all(&temp_root_path).expect("stale temp root");
136
137        cleanup_stale_installations().expect("cleanup should succeed");
138
139        let stored = database::get_package(&conn, &package.name)
140            .expect("query package")
141            .expect("package should still exist");
142        assert_eq!(stored.status, PackageStatus::Failed);
143        assert!(!install_dir.exists());
144        assert!(!temp_root_path.exists());
145    }
146}