1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use tracing::warn;
6
7use crate::core::paths::install_root_from_package_dir;
8use crate::database;
9use crate::models::domains::command_resolution::ResolverResult;
10use crate::operations::install;
11use crate::operations::shims;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum JournalCommandResolutionStatus {
15 Unknown,
16 Fresh,
17 Stale {
18 committed_fingerprint: String,
19 current_fingerprint: String,
20 },
21}
22
23#[derive(Debug, Clone)]
24pub struct JournalReplayTarget {
25 pub journal_path: PathBuf,
26 pub committed: database::CommittedJournalPackage,
27 pub command_resolution_status: JournalCommandResolutionStatus,
28}
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct JournalReplaySummary {
32 pub total: usize,
33 pub fresh: usize,
34 pub stale: usize,
35 pub unknown: usize,
36}
37
38pub fn replay_committed_journals(journal_paths: &[PathBuf]) -> Result<usize> {
39 let targets = prepare_journal_replay_targets(journal_paths)?;
40 replay_prepared_journal_targets(&targets)
41}
42
43pub fn prepare_journal_replay_targets(
44 journal_paths: &[PathBuf],
45) -> Result<Vec<JournalReplayTarget>> {
46 let catalog_conn = match database::get_catalog_conn() {
47 Ok(conn) => Some(conn),
48 Err(err) => {
49 warn!(
50 error = %err,
51 "failed to open catalog database for repair command resolution comparison"
52 );
53 None
54 }
55 };
56 let mut targets = Vec::with_capacity(journal_paths.len());
57
58 for journal_path in journal_paths {
59 let committed = database::JournalReader::read_committed_package(journal_path)
60 .with_context(|| {
61 format!(
62 "failed to parse committed journal at {}",
63 journal_path.display()
64 )
65 })?;
66
67 if committed.command_resolution.is_none() {
68 bail!(
69 "committed journal at {} is missing command resolution metadata",
70 journal_path.display()
71 );
72 }
73
74 let current_resolution = catalog_conn
75 .as_ref()
76 .and_then(|conn| current_command_resolution(conn, &committed.package.name));
77
78 let command_resolution_status = classify_journal_command_resolution_status(
79 committed.command_resolution.as_ref(),
80 current_resolution,
81 );
82
83 if let JournalCommandResolutionStatus::Stale {
84 committed_fingerprint,
85 current_fingerprint,
86 } = &command_resolution_status
87 {
88 warn!(
89 package = committed.package.name.as_str(),
90 committed_fingerprint = committed_fingerprint.as_str(),
91 current_fingerprint = current_fingerprint.as_str(),
92 "committed journal command resolution fingerprint differs from current catalog metadata"
93 );
94 }
95
96 targets.push(JournalReplayTarget {
97 journal_path: journal_path.clone(),
98 committed,
99 command_resolution_status,
100 });
101 }
102
103 Ok(targets)
104}
105
106pub fn replay_prepared_journal_targets(targets: &[JournalReplayTarget]) -> Result<usize> {
107 let mut conn = database::get_conn()?;
108 let mut replayed = 0usize;
109
110 for target in targets {
111 let committed = &target.committed;
112 let previous_commands = database::list_commands_for_package(&conn, &committed.package.name)
113 .unwrap_or_else(|err| {
114 warn!(
115 package = committed.package.name.as_str(),
116 error = %err,
117 "failed to read existing package commands before replay"
118 );
119 Vec::new()
120 });
121 database::replay_committed_journal(&mut conn, committed).with_context(|| {
122 format!(
123 "failed to replay committed journal at {}",
124 target.journal_path.display()
125 )
126 })?;
127 let shims_root =
128 install_root_from_package_dir(Path::new(&committed.package.install_dir)).join("shims");
129 let desired_commands = journal_commands(committed);
130 let targets = journal_shim_targets(committed);
131
132 if let Err(err) = shims::publish_shims_for_install_dir(
133 &shims_root,
134 Path::new(&committed.package.install_dir),
135 desired_commands,
136 &targets,
137 ) {
138 warn!(
139 package = committed.package.name.as_str(),
140 error = %err,
141 "failed to publish package shims during repair replay"
142 );
143 } else {
144 let desired_commands = desired_commands.iter().cloned().collect::<BTreeSet<_>>();
145 let stale_commands = previous_commands
146 .into_iter()
147 .filter(|command| !desired_commands.contains(command))
148 .collect::<Vec<_>>();
149
150 if !stale_commands.is_empty()
151 && let Err(err) = shims::remove_shim_files(&shims_root, &stale_commands)
152 {
153 warn!(
154 package = committed.package.name.as_str(),
155 error = %err,
156 "failed to remove stale package shims during repair replay"
157 );
158 }
159 }
160 replayed += 1;
161 }
162
163 Ok(replayed)
164}
165
166pub fn summarize_journal_replay_targets(targets: &[JournalReplayTarget]) -> JournalReplaySummary {
167 let mut summary = JournalReplaySummary {
168 total: targets.len(),
169 ..JournalReplaySummary::default()
170 };
171
172 for target in targets {
173 match target.command_resolution_status {
174 JournalCommandResolutionStatus::Fresh => summary.fresh += 1,
175 JournalCommandResolutionStatus::Stale { .. } => summary.stale += 1,
176 JournalCommandResolutionStatus::Unknown => summary.unknown += 1,
177 }
178 }
179
180 summary
181}
182
183fn journal_commands(committed: &database::CommittedJournalPackage) -> &[String] {
184 match committed.command_resolution.as_ref() {
185 Some(ResolverResult::Resolved { commands, .. }) => commands.as_slice(),
186 Some(ResolverResult::Unresolved { .. }) | None => &[],
187 }
188}
189
190fn journal_shim_targets(committed: &database::CommittedJournalPackage) -> Vec<shims::ShimTarget> {
191 if let Some(bindings) = committed.bin_bindings.as_deref() {
192 return shims::shim_targets_from_journal_bindings(bindings);
193 }
194
195 let empty_paths: &[String] = &[];
196 shims::legacy_shim_targets(committed.bin.as_deref().unwrap_or(empty_paths))
197}
198
199fn current_command_resolution(
200 catalog_conn: &database::DbConnection,
201 package_id: &str,
202) -> Option<ResolverResult> {
203 let package = match database::get_package_by_id(catalog_conn, package_id) {
204 Ok(Some(package)) => package,
205 Ok(None) => return None,
206 Err(err) => {
207 warn!(
208 package = package_id,
209 error = %err,
210 "failed to read catalog package for repair command resolution comparison"
211 );
212 return None;
213 }
214 };
215
216 let installers = match database::get_installers(catalog_conn, package.id.as_str()) {
217 Ok(installers) => installers,
218 Err(err) => {
219 warn!(
220 package = package_id,
221 error = %err,
222 "failed to read catalog installers for repair command resolution comparison"
223 );
224 return None;
225 }
226 };
227
228 let selection_context = crate::catalog::SelectionContext::new(
229 crate::windows::host::host_profile(),
230 crate::windows::host::is_elevated(),
231 );
232 let installer = match install::types::select_installer(&installers, selection_context) {
233 Ok(installer) => installer,
234 Err(err) => {
235 warn!(
236 package = package_id,
237 error = %err,
238 "failed to select catalog installer for repair command resolution comparison"
239 );
240 return None;
241 }
242 };
243
244 match crate::models::domains::command_resolution::resolve_command_exposure(&package, &installer)
245 {
246 Ok(resolution) => Some(resolution),
247 Err(err) => {
248 warn!(
249 package = package_id,
250 error = %err,
251 "failed to resolve current command exposure for repair comparison"
252 );
253 None
254 }
255 }
256}
257
258pub(crate) fn classify_journal_command_resolution_status(
259 committed: Option<&ResolverResult>,
260 current: Option<ResolverResult>,
261) -> JournalCommandResolutionStatus {
262 let Some(committed_resolution) = committed else {
263 return JournalCommandResolutionStatus::Unknown;
264 };
265
266 let ResolverResult::Resolved {
267 catalog_fingerprint: committed_fingerprint,
268 ..
269 } = committed_resolution
270 else {
271 return JournalCommandResolutionStatus::Unknown;
272 };
273
274 let Some(current_resolution) = current.as_ref() else {
275 return JournalCommandResolutionStatus::Unknown;
276 };
277
278 let ResolverResult::Resolved {
279 catalog_fingerprint: current_fingerprint,
280 ..
281 } = current_resolution
282 else {
283 return JournalCommandResolutionStatus::Unknown;
284 };
285
286 if !command_resolution_is_stale(committed_resolution, current_resolution) {
287 JournalCommandResolutionStatus::Fresh
288 } else {
289 JournalCommandResolutionStatus::Stale {
290 committed_fingerprint: committed_fingerprint.clone(),
291 current_fingerprint: current_fingerprint.clone(),
292 }
293 }
294}
295
296pub(crate) fn command_resolution_is_stale(
297 committed: &ResolverResult,
298 current: &ResolverResult,
299) -> bool {
300 match (committed, current) {
301 (
302 ResolverResult::Resolved {
303 catalog_fingerprint: committed_fingerprint,
304 ..
305 },
306 ResolverResult::Resolved {
307 catalog_fingerprint: current_fingerprint,
308 ..
309 },
310 ) => committed_fingerprint != current_fingerprint,
311 _ => false,
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::{
318 JournalCommandResolutionStatus, JournalReplayTarget,
319 classify_journal_command_resolution_status, command_resolution_is_stale,
320 summarize_journal_replay_targets,
321 };
322 use crate::models::domains::command_resolution::{
323 CommandSource, Confidence, ResolverResult, VersionScope,
324 };
325 use crate::models::domains::install::{EngineKind, InstallerType};
326 use crate::models::domains::installed::{InstalledPackage, PackageStatus};
327 use crate::models::domains::shared::DeploymentKind;
328 use std::path::PathBuf;
329
330 fn test_committed_package() -> crate::database::CommittedJournalPackage {
331 crate::database::CommittedJournalPackage {
332 journal_path: PathBuf::from("C:/winbrew/pkgdb/Contoso.App/journal.jsonl"),
333 entries: Vec::new(),
334 package: InstalledPackage {
335 name: "Contoso.App".to_string(),
336 version: "1.0.0".to_string(),
337 kind: InstallerType::Portable,
338 deployment_kind: DeploymentKind::Portable,
339 engine_kind: EngineKind::Portable,
340 engine_metadata: None,
341 install_dir: "C:/winbrew/packages/Contoso.App".to_string(),
342 dependencies: Vec::new(),
343 status: PackageStatus::Ok,
344 installed_at: "2026-04-12T00:00:00Z".to_string(),
345 },
346 commands: Some(vec!["contoso".to_string()]),
347 bin: Some(vec!["bin/tool.exe".to_string()]),
348 bin_bindings: None,
349 env_add_path: Vec::new(),
350 command_resolution: Some(ResolverResult::Resolved {
351 commands: vec!["contoso".to_string()],
352 confidence: Confidence::High,
353 sources: vec![CommandSource::PackageLevel],
354 version_scope: VersionScope::Specific("1.0.0".to_string()),
355 catalog_fingerprint: "sha256:deadbeef".to_string(),
356 }),
357 }
358 }
359
360 fn test_journal_target(status: JournalCommandResolutionStatus) -> JournalReplayTarget {
361 JournalReplayTarget {
362 journal_path: PathBuf::from("C:/winbrew/pkgdb/Contoso.App/journal.jsonl"),
363 committed: test_committed_package(),
364 command_resolution_status: status,
365 }
366 }
367
368 #[test]
369 fn command_resolution_is_stale_when_fingerprints_differ() {
370 let committed = ResolverResult::Resolved {
371 commands: vec!["contoso".to_string()],
372 confidence: Confidence::High,
373 sources: vec![CommandSource::PackageLevel],
374 version_scope: VersionScope::Specific("1.0.0".to_string()),
375 catalog_fingerprint: "sha256:deadbeef".to_string(),
376 };
377 let current = ResolverResult::Resolved {
378 commands: vec!["contoso".to_string()],
379 confidence: Confidence::High,
380 sources: vec![CommandSource::PackageLevel],
381 version_scope: VersionScope::Specific("1.0.0".to_string()),
382 catalog_fingerprint: "sha256:cafebabe".to_string(),
383 };
384
385 assert!(command_resolution_is_stale(&committed, ¤t));
386 }
387
388 #[test]
389 fn classify_journal_command_resolution_status_tracks_fresh_stale_and_unknown_states() {
390 let committed = ResolverResult::Resolved {
391 commands: vec!["contoso".to_string()],
392 confidence: Confidence::High,
393 sources: vec![CommandSource::PackageLevel],
394 version_scope: VersionScope::Specific("1.0.0".to_string()),
395 catalog_fingerprint: "sha256:deadbeef".to_string(),
396 };
397 let current = ResolverResult::Resolved {
398 commands: vec!["contoso".to_string()],
399 confidence: Confidence::High,
400 sources: vec![CommandSource::PackageLevel],
401 version_scope: VersionScope::Specific("1.0.0".to_string()),
402 catalog_fingerprint: "sha256:deadbeef".to_string(),
403 };
404 let stale = ResolverResult::Resolved {
405 commands: vec!["contoso".to_string()],
406 confidence: Confidence::High,
407 sources: vec![CommandSource::PackageLevel],
408 version_scope: VersionScope::Specific("1.0.0".to_string()),
409 catalog_fingerprint: "sha256:cafebabe".to_string(),
410 };
411
412 assert!(matches!(
413 classify_journal_command_resolution_status(Some(&committed), Some(current)),
414 JournalCommandResolutionStatus::Fresh
415 ));
416 assert!(matches!(
417 classify_journal_command_resolution_status(Some(&committed), Some(stale)),
418 JournalCommandResolutionStatus::Stale {
419 committed_fingerprint,
420 current_fingerprint,
421 } if committed_fingerprint == "sha256:deadbeef" && current_fingerprint == "sha256:cafebabe"
422 ));
423 assert!(matches!(
424 classify_journal_command_resolution_status(None, None),
425 JournalCommandResolutionStatus::Unknown
426 ));
427 }
428
429 #[test]
430 fn summarize_journal_replay_targets_counts_statuses() {
431 let summary = summarize_journal_replay_targets(&[
432 test_journal_target(JournalCommandResolutionStatus::Fresh),
433 test_journal_target(JournalCommandResolutionStatus::Stale {
434 committed_fingerprint: "sha256:deadbeef".to_string(),
435 current_fingerprint: "sha256:cafebabe".to_string(),
436 }),
437 test_journal_target(JournalCommandResolutionStatus::Unknown),
438 ]);
439
440 assert_eq!(summary.total, 3);
441 assert_eq!(summary.fresh, 1);
442 assert_eq!(summary.stale, 1);
443 assert_eq!(summary.unknown, 1);
444 }
445}