1use 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#[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
67pub type Result<T> = std::result::Result<T, InstallStateError>;
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct CommandConflict {
73 pub command: String,
74 pub package: String,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum InstallTargetState {
80 Ready,
81 AlreadyInstalled,
82 AlreadyInstalling,
83 CurrentlyUpdating,
84 Failed,
85 Orphaned,
86}
87
88#[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
108pub 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
123pub 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
184pub 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
197pub 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
227pub 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 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}