1use std::collections::HashMap;
2use std::io::ErrorKind;
3use std::path::Path;
4
5use crate::core::paths::ResolvedPaths;
6use crate::database;
7use crate::models::domains::installed::InstalledPackage;
8use crate::models::domains::reporting::{DiagnosisResult, DiagnosisSeverity};
9use crate::models::domains::shared::DeploymentKind;
10use tracing::debug;
11
12use super::{PackageJournalScan, ScanResult, sort_diagnoses, sort_recovery_findings};
13
14mod error_codes {
15 pub const PKGDB_UNREADABLE: &str = "pkgdb_unreadable";
16 pub const INCOMPLETE_JOURNAL: &str = "incomplete_package_journal";
17 pub const UNREADABLE_JOURNAL: &str = "unreadable_package_journal";
18 pub const MALFORMED_JOURNAL: &str = "malformed_package_journal";
19 pub const TRAILING_JOURNAL: &str = "trailing_package_journal";
20 pub const MISSING_METADATA: &str = "missing_journal_metadata";
21 pub const ORPHAN_JOURNAL: &str = "orphan_package_journal";
22 pub const STALE_JOURNAL: &str = "stale_package_journal";
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(super) struct JournalMetadata<'a> {
27 pub(super) package_id: &'a str,
28 pub(super) version: &'a str,
29 pub(super) engine: &'a str,
30 pub(super) deployment_kind: DeploymentKind,
31 pub(super) install_dir: &'a str,
32}
33
34#[inline]
44fn diagnosis(
45 error_code: &str,
46 description: String,
47 severity: DiagnosisSeverity,
48) -> DiagnosisResult {
49 DiagnosisResult {
50 error_code: error_code.to_string(),
51 description,
52 severity,
53 }
54}
55
56#[inline]
71fn journal_error(
72 journal_path: &Path,
73 error_code: &str,
74 message: impl std::fmt::Display,
75 severity: DiagnosisSeverity,
76) -> DiagnosisResult {
77 diagnosis(
78 error_code,
79 format!("{}: {}", journal_path.to_string_lossy(), message),
80 severity,
81 )
82}
83
84fn journal_read_error_diagnosis(
96 journal_path: &Path,
97 error: database::JournalReadError,
98) -> (DiagnosisResult, Option<&Path>) {
99 match error {
100 database::JournalReadError::Incomplete { .. } => (
101 journal_error(
102 journal_path,
103 error_codes::INCOMPLETE_JOURNAL,
104 "incomplete recovery journal",
105 DiagnosisSeverity::Error,
106 ),
107 None,
108 ),
109 database::JournalReadError::Read { .. } => (
110 journal_error(
111 journal_path,
112 error_codes::UNREADABLE_JOURNAL,
113 "recovery journal is unreadable",
114 DiagnosisSeverity::Error,
115 ),
116 None,
117 ),
118 database::JournalReadError::MalformedLine { line, .. } => (
119 journal_error(
120 journal_path,
121 error_codes::MALFORMED_JOURNAL,
122 format!("recovery journal has malformed line {line}"),
123 DiagnosisSeverity::Error,
124 ),
125 None,
126 ),
127 database::JournalReadError::TrailingEntries { line, .. } => (
128 journal_error(
129 journal_path,
130 error_codes::TRAILING_JOURNAL,
131 format!("recovery journal has trailing entries after commit on line {line}"),
132 DiagnosisSeverity::Error,
133 ),
134 Some(journal_path),
135 ),
136 }
137}
138
139pub(super) fn extract_journal_metadata(
140 entries: &[database::JournalEntry],
141) -> Option<JournalMetadata<'_>> {
142 entries.iter().find_map(|entry| match entry {
143 database::JournalEntry::Metadata {
144 package_id,
145 version,
146 engine,
147 deployment_kind,
148 install_dir,
149 dependencies: _,
150 commands: _,
151 bin: _,
152 bin_bindings: _,
153 env_add_path: _,
154 command_resolution: _,
155 engine_metadata: _,
156 } => Some(JournalMetadata {
157 package_id: package_id.as_str(),
158 version: version.as_str(),
159 engine: engine.as_str(),
160 deployment_kind: *deployment_kind,
161 install_dir: install_dir.as_str(),
162 }),
163 _ => None,
164 })
165}
166
167pub(super) fn journal_metadata_matches_package(
168 package: &InstalledPackage,
169 metadata: &JournalMetadata<'_>,
170) -> bool {
171 package.version == metadata.version
172 && package
173 .engine_kind
174 .as_str()
175 .eq_ignore_ascii_case(metadata.engine)
176 && package.install_dir == metadata.install_dir
177 && package.deployment_kind == metadata.deployment_kind
178}
179
180fn process_journal_entry(
181 entry_path: &Path,
182 package_lookup: &HashMap<&str, &InstalledPackage>,
183 result: &mut PackageJournalScan,
184) {
185 let journal_path = entry_path.join("journal.jsonl");
186
187 match database::JournalReader::read_committed(&journal_path) {
188 Ok(entries) => {
189 for diagnosis in diagnose_committed_journal(&journal_path, &entries, package_lookup) {
190 result.push(diagnosis, Some(&journal_path));
191 }
192 }
193 Err(database::JournalReadError::Read { source, .. })
194 if source.kind() == ErrorKind::NotFound =>
195 {
196 debug!(path = %journal_path.display(), "missing journal file, skipping package directory");
197 }
198 Err(error) => {
199 let (diagnosis, target_path) = journal_read_error_diagnosis(&journal_path, error);
200 result.push(diagnosis, target_path);
201 }
202 }
203}
204
205pub(super) fn scan_package_journals(
207 paths: &ResolvedPaths,
208 packages: &[InstalledPackage],
209) -> PackageJournalScan {
210 let pkgdb_root = &paths.pkgdb;
211
212 if !pkgdb_root.exists() {
213 debug!(path = %pkgdb_root.display(), "pkgdb root does not exist, skipping journal scan");
214 return ScanResult::default();
215 }
216
217 let package_lookup: HashMap<&str, &InstalledPackage> = packages
218 .iter()
219 .map(|package| (package.name.as_str(), package))
220 .collect();
221
222 let entries = match std::fs::read_dir(pkgdb_root) {
223 Ok(entries) => entries,
224 Err(err) => {
225 if err.kind() == std::io::ErrorKind::NotFound {
226 debug!(path = %pkgdb_root.display(), "pkgdb root disappeared before journal scan");
227 return ScanResult::default();
228 }
229
230 let mut result = ScanResult::default();
231 result.push(
232 diagnosis(
233 error_codes::PKGDB_UNREADABLE,
234 format!(
235 "pkgdb root: unreadable journal directory ({}) - {err}",
236 pkgdb_root.to_string_lossy()
237 ),
238 DiagnosisSeverity::Error,
239 ),
240 None,
241 );
242 return result;
243 }
244 };
245
246 let mut result = ScanResult::default();
247
248 for entry_result in entries {
249 let entry = match entry_result {
250 Ok(entry) => entry,
251 Err(err) => {
252 debug!(path = %pkgdb_root.display(), error = %err, "skipping unreadable pkgdb entry");
253 continue;
254 }
255 };
256
257 let entry_path = entry.path();
258
259 let file_type = match entry.file_type() {
260 Ok(file_type) => file_type,
261 Err(err) => {
262 debug!(path = %entry_path.display(), error = %err, "skipping pkgdb entry with unreadable file type");
263 continue;
264 }
265 };
266
267 if !file_type.is_dir() {
268 continue;
269 }
270
271 process_journal_entry(&entry_path, &package_lookup, &mut result);
272 }
273
274 sort_diagnoses(&mut result.diagnostics);
275 sort_recovery_findings(&mut result.recovery_findings);
276
277 result
278}
279
280fn diagnose_committed_journal(
281 journal_path: &Path,
282 entries: &[database::JournalEntry],
283 packages: &HashMap<&str, &InstalledPackage>,
284) -> Vec<DiagnosisResult> {
285 let Some(metadata) = extract_journal_metadata(entries) else {
286 return vec![journal_error(
287 journal_path,
288 error_codes::MISSING_METADATA,
289 "committed recovery journal is missing metadata",
290 DiagnosisSeverity::Error,
291 )];
292 };
293
294 diagnose_committed_journal_metadata(journal_path, &metadata, packages)
295 .map(|diagnosis| vec![diagnosis])
296 .unwrap_or_default()
297}
298
299fn diagnose_committed_journal_metadata(
300 journal_path: &Path,
301 metadata: &JournalMetadata<'_>,
302 packages: &HashMap<&str, &InstalledPackage>,
303) -> Option<DiagnosisResult> {
304 let Some(package) = packages.get(metadata.package_id) else {
305 return Some(journal_error(
306 journal_path,
307 error_codes::ORPHAN_JOURNAL,
308 "committed recovery journal has no installed package",
309 DiagnosisSeverity::Warning,
310 ));
311 };
312
313 if !journal_metadata_matches_package(package, metadata) {
314 return Some(journal_error(
315 journal_path,
316 error_codes::STALE_JOURNAL,
317 format!(
318 "recovery journal does not match installed package {} ({})",
319 package.name, package.version
320 ),
321 DiagnosisSeverity::Warning,
322 ));
323 }
324
325 None
326}
327
328#[cfg(test)]
329mod tests {
330 use super::{
331 JournalMetadata, diagnose_committed_journal_metadata, extract_journal_metadata,
332 journal_metadata_matches_package, scan_package_journals,
333 };
334 use crate::core::paths::{ResolvedPaths, resolved_paths};
335 use crate::database;
336 use crate::models::domains::install::{EngineKind, InstallerType};
337 use crate::models::domains::installed::{InstalledPackage, PackageStatus};
338 use crate::models::domains::reporting::{
339 DiagnosisResult, DiagnosisSeverity, RecoveryActionGroup, RecoveryFinding, RecoveryIssueKind,
340 };
341 use crate::models::domains::shared::DeploymentKind;
342 use std::fs;
343 use std::path::{Path, PathBuf};
344 use tempfile::{TempDir, tempdir};
345
346 struct TestEnvironment {
347 _root: TempDir,
348 paths: ResolvedPaths,
349 }
350
351 impl TestEnvironment {
352 fn new() -> Self {
353 let root = tempdir().expect("temp dir should be created");
354 let paths = Self::build_paths(root.path());
355
356 Self { _root: root, paths }
357 }
358
359 fn build_paths(root: &Path) -> ResolvedPaths {
360 let packages = root.join("packages").to_string_lossy().into_owned();
361 let data = root.join("data").to_string_lossy().into_owned();
362 let logs = root.join("logs").to_string_lossy().into_owned();
363 let cache = root.join("cache").to_string_lossy().into_owned();
364
365 resolved_paths(root, &packages, &data, &logs, &cache)
366 }
367
368 fn root(&self) -> &Path {
369 self._root.path()
370 }
371
372 fn pkgdb_root(&self) -> &Path {
373 &self.paths.pkgdb
374 }
375
376 fn create_dir(&self, path: &Path) {
377 fs::create_dir_all(path).expect("directory should be created");
378 }
379
380 fn write_file(&self, path: &Path, content: &[u8]) {
381 if let Some(parent) = path.parent() {
382 fs::create_dir_all(parent).expect("parent directory should be created");
383 }
384
385 fs::write(path, content).expect("file should be written");
386 }
387
388 fn journal_path(&self, package_name: &str) -> PathBuf {
389 self.pkgdb_root().join(package_name).join("journal.jsonl")
390 }
391 }
392
393 fn assert_single_diagnosis<'a>(
394 diagnostics: &'a [DiagnosisResult],
395 expected_error_code: &str,
396 expected_severity: DiagnosisSeverity,
397 ) -> &'a DiagnosisResult {
398 assert_eq!(diagnostics.len(), 1, "expected exactly one diagnosis");
399
400 let diagnosis = &diagnostics[0];
401 assert_eq!(diagnosis.error_code, expected_error_code);
402 assert_eq!(diagnosis.severity, expected_severity);
403
404 diagnosis
405 }
406
407 fn assert_single_recovery_finding(
408 findings: &[RecoveryFinding],
409 expected_issue_kind: RecoveryIssueKind,
410 expected_action_group: Option<RecoveryActionGroup>,
411 ) -> &RecoveryFinding {
412 assert_eq!(findings.len(), 1, "expected exactly one recovery finding");
413
414 let finding = &findings[0];
415 assert_eq!(finding.issue_kind, expected_issue_kind);
416 assert_eq!(finding.action_group, expected_action_group);
417
418 finding
419 }
420
421 fn assert_recovery_target_path(finding: &RecoveryFinding, expected_path: &Path) {
422 let expected_path = expected_path.to_string_lossy().to_string();
423 assert_eq!(finding.target_path.as_deref(), Some(expected_path.as_str()));
424 }
425
426 fn journal_metadata_entry(package_name: &str) -> database::JournalEntry {
427 database::JournalEntry::Metadata {
428 package_id: package_name.to_string(),
429 version: "1.0.0".to_string(),
430 engine: "msi".to_string(),
431 deployment_kind: DeploymentKind::Installed,
432 install_dir: format!(r"C:\winbrew\apps\{package_name}"),
433 dependencies: Vec::new(),
434 commands: None,
435 bin: None,
436 bin_bindings: None,
437 env_add_path: Vec::new(),
438 command_resolution: None,
439 engine_metadata: None,
440 }
441 }
442
443 fn journal_commit_entry() -> database::JournalEntry {
444 database::JournalEntry::Commit {
445 installed_at: "2026-04-12T00:00:00Z".to_string(),
446 }
447 }
448
449 fn write_journal(
450 env: &TestEnvironment,
451 package_name: &str,
452 build: impl FnOnce(&mut database::JournalWriter),
453 ) -> PathBuf {
454 let package_key = database::package_journal_key(package_name, "1.0.0");
455 fs::create_dir_all(env.root().join("data").join("pkgdb").join(&package_key))
456 .expect("journal package directory should be created");
457 let mut writer =
458 database::JournalWriter::open_for_package(env.root(), package_name, "1.0.0")
459 .expect("open journal");
460 build(&mut writer);
461 writer.flush().expect("flush journal");
462 writer.path().to_path_buf()
463 }
464
465 fn write_metadata_only_journal(env: &TestEnvironment, package_name: &str) {
466 let _ = write_journal(env, package_name, |writer| {
467 writer
468 .append(&journal_metadata_entry(package_name))
469 .expect("write metadata");
470 });
471 }
472
473 fn write_commit_only_journal(env: &TestEnvironment, package_name: &str) -> PathBuf {
474 write_journal(env, package_name, |writer| {
475 writer
476 .append(&journal_commit_entry())
477 .expect("write commit");
478 })
479 }
480
481 fn write_committed_journal(env: &TestEnvironment, package_name: &str) -> PathBuf {
482 write_journal(env, package_name, |writer| {
483 writer
484 .append(&journal_metadata_entry(package_name))
485 .expect("write metadata");
486 writer
487 .append(&journal_commit_entry())
488 .expect("write commit");
489 })
490 }
491
492 fn write_committed_journal_with_trailing_entry(
493 env: &TestEnvironment,
494 package_name: &str,
495 trailing_path: &str,
496 ) -> PathBuf {
497 write_journal(env, package_name, |writer| {
498 writer
499 .append(&journal_metadata_entry(package_name))
500 .expect("write metadata");
501 writer
502 .append(&journal_commit_entry())
503 .expect("write commit");
504 writer
505 .append(&database::JournalEntry::FsCreate {
506 path: trailing_path.to_string(),
507 hash: None,
508 })
509 .expect("write trailing entry");
510 })
511 }
512
513 fn sample_package() -> InstalledPackage {
514 InstalledPackage {
515 name: "Contoso.App".to_string(),
516 version: "1.0.0".to_string(),
517 kind: InstallerType::Msi,
518 deployment_kind: DeploymentKind::Installed,
519 engine_kind: EngineKind::Msi,
520 engine_metadata: None,
521 install_dir: r"C:\winbrew\apps\Contoso.App".to_string(),
522 dependencies: Vec::new(),
523 status: PackageStatus::Ok,
524 installed_at: "2026-04-12T00:00:00Z".to_string(),
525 }
526 }
527
528 #[test]
529 fn extract_journal_metadata_returns_structured_metadata() {
530 let entries = vec![
531 crate::database::JournalEntry::FsCreate {
532 path: r"C:\winbrew\apps\Contoso.App\bin\tool.exe".to_string(),
533 hash: None,
534 },
535 journal_metadata_entry("Contoso.App"),
536 journal_commit_entry(),
537 ];
538
539 let metadata = extract_journal_metadata(&entries).expect("metadata should be found");
540
541 assert_eq!(metadata.package_id, "Contoso.App");
542 assert_eq!(metadata.version, "1.0.0");
543 assert_eq!(metadata.engine, "msi");
544 assert_eq!(metadata.deployment_kind, DeploymentKind::Installed);
545 assert_eq!(metadata.install_dir, r"C:\winbrew\apps\Contoso.App");
546 }
547
548 #[test]
549 fn extract_journal_metadata_returns_none_when_no_metadata_entry() {
550 let entries = vec![journal_commit_entry()];
551
552 assert!(extract_journal_metadata(&entries).is_none());
553 }
554
555 #[test]
556 fn journal_metadata_matches_package_accepts_matching_package_fields() {
557 let package = sample_package();
558 let metadata = JournalMetadata {
559 package_id: "Contoso.App",
560 version: "1.0.0",
561 engine: "msi",
562 deployment_kind: DeploymentKind::Installed,
563 install_dir: r"C:\winbrew\apps\Contoso.App",
564 };
565
566 assert!(journal_metadata_matches_package(&package, &metadata));
567 }
568
569 #[test]
570 fn journal_metadata_matches_package_engine_comparison_is_case_insensitive() {
571 let package = sample_package();
572 let metadata = JournalMetadata {
573 package_id: "Contoso.App",
574 version: "1.0.0",
575 engine: "MSI",
576 deployment_kind: DeploymentKind::Installed,
577 install_dir: r"C:\winbrew\apps\Contoso.App",
578 };
579
580 assert!(journal_metadata_matches_package(&package, &metadata));
581 }
582
583 #[test]
584 fn diagnose_committed_journal_metadata_returns_stale_diagnosis_for_changed_package() {
585 let package = sample_package();
586 let metadata = JournalMetadata {
587 package_id: "Contoso.App",
588 version: "0.9.0",
589 engine: "msi",
590 deployment_kind: DeploymentKind::Installed,
591 install_dir: r"C:\winbrew\apps\Contoso.App",
592 };
593 let packages = std::collections::HashMap::from([(package.name.as_str(), &package)]);
594
595 let diagnosis = diagnose_committed_journal_metadata(
596 &PathBuf::from(r"C:\winbrew\pkgdb\Contoso.App\journal.jsonl"),
597 &metadata,
598 &packages,
599 )
600 .expect("stale package should produce a diagnosis");
601
602 assert_eq!(diagnosis.error_code, "stale_package_journal");
603 }
604
605 #[test]
606 fn scan_package_journals_detects_stale_committed_journal() {
607 let env = TestEnvironment::new();
608 let journal_path = write_committed_journal(&env, "Contoso.Stale");
609
610 let mut package = sample_package();
611 package.name = "Contoso.Stale".to_string();
612 package.version = "2.0.0".to_string();
613 package.install_dir = r"C:\winbrew\apps\Contoso.Stale".to_string();
614
615 let scan = scan_package_journals(&env.paths, &[package]);
616
617 let diagnosis = assert_single_diagnosis(
618 &scan.diagnostics,
619 "stale_package_journal",
620 DiagnosisSeverity::Warning,
621 );
622 assert!(
623 diagnosis
624 .description
625 .contains("recovery journal does not match")
626 );
627
628 let finding = assert_single_recovery_finding(
629 &scan.recovery_findings,
630 RecoveryIssueKind::Conflict,
631 Some(RecoveryActionGroup::JournalReplay),
632 );
633 assert_recovery_target_path(finding, &journal_path);
634 }
635
636 #[test]
637 fn scan_package_journals_detects_incomplete_journal() {
638 let env = TestEnvironment::new();
639
640 write_metadata_only_journal(&env, "Contoso.Recover");
641
642 let scan = scan_package_journals(&env.paths, &[]);
643
644 assert_single_diagnosis(
645 &scan.diagnostics,
646 "incomplete_package_journal",
647 DiagnosisSeverity::Error,
648 );
649
650 let finding = assert_single_recovery_finding(
651 &scan.recovery_findings,
652 RecoveryIssueKind::RecoveryTrailMissing,
653 None,
654 );
655 assert!(finding.target_path.is_none());
656 }
657
658 #[test]
659 fn scan_package_journals_detects_malformed_journal() {
660 let env = TestEnvironment::new();
661
662 let journal_path = env.journal_path("Contoso.Malformed");
663 env.write_file(&journal_path, b"{not-json}\n");
664
665 let scan = scan_package_journals(&env.paths, &[]);
666
667 assert_single_diagnosis(
668 &scan.diagnostics,
669 "malformed_package_journal",
670 DiagnosisSeverity::Error,
671 );
672
673 let finding = assert_single_recovery_finding(
674 &scan.recovery_findings,
675 RecoveryIssueKind::RecoveryTrailMissing,
676 None,
677 );
678 assert!(finding.target_path.is_none());
679 }
680
681 #[test]
682 fn scan_package_journals_skips_package_directory_without_journal_file() {
683 let env = TestEnvironment::new();
684
685 let journal_dir = env.pkgdb_root().join("Contoso.MissingJournal");
686 env.create_dir(&journal_dir);
687
688 let scan = scan_package_journals(&env.paths, &[]);
689
690 assert!(scan.diagnostics.is_empty());
691 assert!(scan.recovery_findings.is_empty());
692 }
693
694 #[test]
695 fn scan_package_journals_reports_missing_journal_metadata() {
696 let env = TestEnvironment::new();
697
698 let journal_path = write_commit_only_journal(&env, "Contoso.MissingMeta");
699
700 let scan = scan_package_journals(&env.paths, &[]);
701
702 assert_single_diagnosis(
703 &scan.diagnostics,
704 "missing_journal_metadata",
705 DiagnosisSeverity::Error,
706 );
707
708 let finding = assert_single_recovery_finding(
709 &scan.recovery_findings,
710 RecoveryIssueKind::RecoveryTrailMissing,
711 None,
712 );
713 assert_recovery_target_path(finding, &journal_path);
714 }
715
716 #[test]
717 fn scan_package_journals_detects_orphan_committed_journal() {
718 let env = TestEnvironment::new();
719
720 let journal_path = write_committed_journal(&env, "Contoso.Orphan");
721
722 let scan = scan_package_journals(&env.paths, &[]);
723
724 let diagnosis = assert_single_diagnosis(
725 &scan.diagnostics,
726 "orphan_package_journal",
727 DiagnosisSeverity::Warning,
728 );
729 assert!(diagnosis.description.contains("no installed package"));
730
731 let finding = assert_single_recovery_finding(
732 &scan.recovery_findings,
733 RecoveryIssueKind::IncompleteInstall,
734 Some(RecoveryActionGroup::JournalReplay),
735 );
736 assert_recovery_target_path(finding, &journal_path);
737 }
738
739 #[test]
740 fn scan_package_journals_tracks_trailing_journal_replay_target() {
741 let env = TestEnvironment::new();
742
743 let journal_path = write_committed_journal_with_trailing_entry(
744 &env,
745 "Contoso.Trailing",
746 r"C:\winbrew\apps\Contoso.Trailing\payload.exe",
747 );
748
749 let scan = scan_package_journals(&env.paths, &[]);
750
751 let diagnosis = assert_single_diagnosis(
752 &scan.diagnostics,
753 "trailing_package_journal",
754 DiagnosisSeverity::Error,
755 );
756 assert!(
757 diagnosis
758 .description
759 .contains("trailing entries after commit")
760 );
761
762 let finding = assert_single_recovery_finding(
763 &scan.recovery_findings,
764 RecoveryIssueKind::Conflict,
765 Some(RecoveryActionGroup::JournalReplay),
766 );
767 assert_recovery_target_path(finding, &journal_path);
768 }
769}