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 "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}