use crate::{ArchiveError, ArchiveResult};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::info;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReportFormat {
Html,
Json,
Csv,
Text,
}
pub async fn generate_report(
pool: &sqlx::SqlitePool,
format: ReportFormat,
output_path: &Path,
) -> ArchiveResult<()> {
info!(
"Generating {} report to {}",
format_name(format),
output_path.display()
);
let report = collect_report_data(pool).await?;
let content = match format {
ReportFormat::Html => generate_html_report(&report)?,
ReportFormat::Json => generate_json_report(&report)?,
ReportFormat::Csv => generate_csv_report(&report)?,
ReportFormat::Text => generate_text_report(&report)?,
};
fs::write(output_path, content).await?;
info!("Report generated successfully");
Ok(())
}
fn format_name(format: ReportFormat) -> &'static str {
match format {
ReportFormat::Html => "HTML",
ReportFormat::Json => "JSON",
ReportFormat::Csv => "CSV",
ReportFormat::Text => "Text",
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerificationReport {
pub generated_at: DateTime<Utc>,
pub summary: ReportSummary,
pub checksum_stats: ChecksumStatistics,
pub fixity_stats: FixityStatistics,
pub quarantine_stats: QuarantineStatistics,
pub recent_events: Vec<RecentEvent>,
pub alerts: Vec<Alert>,
pub integrity_metrics: IntegrityMetrics,
pub file_details: Vec<FileDetail>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportSummary {
pub total_files: usize,
pub total_size: u64,
pub verified_files: usize,
pub failed_files: usize,
pub quarantined_files: usize,
pub last_check_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChecksumStatistics {
pub total_checksums: usize,
pub blake3_count: usize,
pub md5_count: usize,
pub sha256_count: usize,
pub crc32_count: usize,
pub oldest_checksum: Option<DateTime<Utc>>,
pub newest_checksum: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixityStatistics {
pub total_checks: usize,
pub passed_checks: usize,
pub failed_checks: usize,
pub success_rate: f64,
pub average_days_between_checks: f64,
pub files_needing_check: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuarantineStatistics {
pub total_quarantined: usize,
pub active_quarantined: usize,
pub restored: usize,
pub auto_quarantined: usize,
pub manual_quarantined: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentEvent {
pub event_type: String,
pub event_date: DateTime<Utc>,
pub file_path: String,
pub outcome: String,
pub details: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alert {
pub alert_type: AlertType,
pub severity: AlertSeverity,
pub message: String,
pub file_path: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AlertType {
FixityCheckFailed,
FileCorrupted,
ChecksumMismatch,
FileQuarantined,
BackupNeeded,
StorageIssue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AlertSeverity {
Info,
Warning,
Error,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrityMetrics {
pub overall_health: f64,
pub checksum_coverage: f64,
pub fixity_compliance: f64,
pub quarantine_rate: f64,
pub data_at_risk: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDetail {
pub file_path: String,
pub file_size: u64,
pub last_verified: Option<DateTime<Utc>>,
pub verification_count: u32,
pub failure_count: u32,
pub status: FileStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileStatus {
Healthy,
NeedsVerification,
Failed,
Quarantined,
}
async fn collect_report_data(pool: &sqlx::SqlitePool) -> ArchiveResult<VerificationReport> {
let summary = collect_summary(pool).await?;
let checksum_stats = collect_checksum_stats(pool).await?;
let fixity_stats = collect_fixity_stats(pool).await?;
let quarantine_stats = collect_quarantine_stats(pool).await?;
let recent_events = collect_recent_events(pool).await?;
let alerts = generate_alerts(pool).await?;
let integrity_metrics = calculate_integrity_metrics(&summary, &fixity_stats, &quarantine_stats);
let file_details = collect_file_details(pool).await?;
Ok(VerificationReport {
generated_at: Utc::now(),
summary,
checksum_stats,
fixity_stats,
quarantine_stats,
recent_events,
alerts,
integrity_metrics,
file_details,
})
}
async fn collect_summary(pool: &sqlx::SqlitePool) -> ArchiveResult<ReportSummary> {
let row = sqlx::query(
r"
SELECT
COUNT(*) as total_files,
SUM(file_size) as total_size,
MAX(last_verified_at) as last_check_date
FROM checksums
",
)
.fetch_one(pool)
.await?;
let total_files: i64 = row.get("total_files");
let total_size: Option<i64> = row.get("total_size");
let last_check_str: Option<String> = row.get("last_check_date");
let last_check_date = last_check_str
.map(|s| DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&Utc)))
.transpose()
.map_err(|e| ArchiveError::Database(sqlx::Error::Decode(Box::new(e))))?;
let verified_row = sqlx::query(
r"
SELECT COUNT(*) as verified
FROM fixity_checks
WHERE status = 'pass'
",
)
.fetch_one(pool)
.await?;
let verified_files: i64 = verified_row.get("verified");
let failed_row = sqlx::query(
r"
SELECT COUNT(DISTINCT file_path) as failed
FROM fixity_checks
WHERE status = 'fail'
",
)
.fetch_one(pool)
.await?;
let failed_files: i64 = failed_row.get("failed");
let quarantine_row = sqlx::query(
r"
SELECT COUNT(*) as quarantined
FROM quarantine_records
WHERE restored = 0
",
)
.fetch_one(pool)
.await?;
let quarantined_files: i64 = quarantine_row.get("quarantined");
Ok(ReportSummary {
total_files: total_files as usize,
total_size: total_size.unwrap_or(0) as u64,
verified_files: verified_files as usize,
failed_files: failed_files as usize,
quarantined_files: quarantined_files as usize,
last_check_date,
})
}
async fn collect_checksum_stats(pool: &sqlx::SqlitePool) -> ArchiveResult<ChecksumStatistics> {
let row = sqlx::query(
r"
SELECT
COUNT(*) as total,
SUM(CASE WHEN blake3 IS NOT NULL THEN 1 ELSE 0 END) as blake3_count,
SUM(CASE WHEN md5 IS NOT NULL THEN 1 ELSE 0 END) as md5_count,
SUM(CASE WHEN sha256 IS NOT NULL THEN 1 ELSE 0 END) as sha256_count,
SUM(CASE WHEN crc32 IS NOT NULL THEN 1 ELSE 0 END) as crc32_count,
MIN(created_at) as oldest,
MAX(created_at) as newest
FROM checksums
",
)
.fetch_one(pool)
.await?;
let total: i64 = row.get("total");
let blake3_count: i64 = row.get("blake3_count");
let md5_count: i64 = row.get("md5_count");
let sha256_count: i64 = row.get("sha256_count");
let crc32_count: i64 = row.get("crc32_count");
let oldest_str: Option<String> = row.get("oldest");
let newest_str: Option<String> = row.get("newest");
let oldest_checksum = oldest_str
.map(|s| DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&Utc)))
.transpose()
.map_err(|e| ArchiveError::Database(sqlx::Error::Decode(Box::new(e))))?;
let newest_checksum = newest_str
.map(|s| DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&Utc)))
.transpose()
.map_err(|e| ArchiveError::Database(sqlx::Error::Decode(Box::new(e))))?;
Ok(ChecksumStatistics {
total_checksums: total as usize,
blake3_count: blake3_count as usize,
md5_count: md5_count as usize,
sha256_count: sha256_count as usize,
crc32_count: crc32_count as usize,
oldest_checksum,
newest_checksum,
})
}
async fn collect_fixity_stats(pool: &sqlx::SqlitePool) -> ArchiveResult<FixityStatistics> {
let row = sqlx::query(
r"
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pass' THEN 1 ELSE 0 END) as passed,
SUM(CASE WHEN status = 'fail' THEN 1 ELSE 0 END) as failed
FROM fixity_checks
",
)
.fetch_one(pool)
.await?;
let total: i64 = row.get("total");
let passed: i64 = row.get("passed");
let failed: i64 = row.get("failed");
let success_rate = if total > 0 {
(passed as f64) / (total as f64)
} else {
0.0
};
let avg_days = 90.0;
let files_needing_check = 0;
Ok(FixityStatistics {
total_checks: total as usize,
passed_checks: passed as usize,
failed_checks: failed as usize,
success_rate,
average_days_between_checks: avg_days,
files_needing_check,
})
}
async fn collect_quarantine_stats(pool: &sqlx::SqlitePool) -> ArchiveResult<QuarantineStatistics> {
let row = sqlx::query(
r"
SELECT
COUNT(*) as total,
SUM(CASE WHEN restored = 0 THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN restored = 1 THEN 1 ELSE 0 END) as restored,
SUM(CASE WHEN auto_quarantine = 1 THEN 1 ELSE 0 END) as auto_q,
SUM(CASE WHEN auto_quarantine = 0 THEN 1 ELSE 0 END) as manual_q
FROM quarantine_records
",
)
.fetch_one(pool)
.await?;
let total: i64 = row.get("total");
let active: i64 = row.get("active");
let restored: i64 = row.get("restored");
let auto_q: i64 = row.get("auto_q");
let manual_q: i64 = row.get("manual_q");
Ok(QuarantineStatistics {
total_quarantined: total as usize,
active_quarantined: active as usize,
restored: restored as usize,
auto_quarantined: auto_q as usize,
manual_quarantined: manual_q as usize,
})
}
async fn collect_recent_events(pool: &sqlx::SqlitePool) -> ArchiveResult<Vec<RecentEvent>> {
let rows = sqlx::query(
r"
SELECT event_type, event_date_time, linking_object_id, event_outcome, event_detail
FROM premis_events
ORDER BY event_date_time DESC
LIMIT 50
",
)
.fetch_all(pool)
.await?;
let mut events = Vec::new();
for row in rows {
let event_date_str: String = row.get("event_date_time");
let event_date = DateTime::parse_from_rfc3339(&event_date_str)
.map_err(|e| ArchiveError::Database(sqlx::Error::Decode(Box::new(e))))?
.with_timezone(&Utc);
events.push(RecentEvent {
event_type: row.get("event_type"),
event_date,
file_path: row.get("linking_object_id"),
outcome: row.get("event_outcome"),
details: row.get("event_detail"),
});
}
Ok(events)
}
async fn generate_alerts(pool: &sqlx::SqlitePool) -> ArchiveResult<Vec<Alert>> {
let mut alerts = Vec::new();
let failed_checks = sqlx::query(
r"
SELECT file_path
FROM fixity_checks
WHERE status = 'fail'
GROUP BY file_path
HAVING COUNT(*) > 0
ORDER BY MAX(check_time) DESC
LIMIT 10
",
)
.fetch_all(pool)
.await?;
for row in failed_checks {
let file_path: String = row.get("file_path");
alerts.push(Alert {
alert_type: AlertType::FixityCheckFailed,
severity: AlertSeverity::Error,
message: "Fixity check failed".to_string(),
file_path: Some(file_path),
created_at: Utc::now(),
});
}
let quarantined = sqlx::query(
r"
SELECT original_path
FROM quarantine_records
WHERE restored = 0
LIMIT 10
",
)
.fetch_all(pool)
.await?;
for row in quarantined {
let file_path: String = row.get("original_path");
alerts.push(Alert {
alert_type: AlertType::FileQuarantined,
severity: AlertSeverity::Warning,
message: "File currently quarantined".to_string(),
file_path: Some(file_path),
created_at: Utc::now(),
});
}
Ok(alerts)
}
fn calculate_integrity_metrics(
summary: &ReportSummary,
fixity: &FixityStatistics,
quarantine: &QuarantineStatistics,
) -> IntegrityMetrics {
let overall_health = if summary.total_files > 0 {
let healthy_files = summary.total_files.saturating_sub(summary.failed_files);
(healthy_files as f64) / (summary.total_files as f64)
} else {
0.0
};
let checksum_coverage = if summary.total_files > 0 {
(summary.verified_files as f64) / (summary.total_files as f64)
} else {
0.0
};
let fixity_compliance = fixity.success_rate;
let quarantine_rate = if summary.total_files > 0 {
(quarantine.active_quarantined as f64) / (summary.total_files as f64)
} else {
0.0
};
let data_at_risk = (summary.failed_files as u64) * 1_000_000;
IntegrityMetrics {
overall_health,
checksum_coverage,
fixity_compliance,
quarantine_rate,
data_at_risk,
}
}
async fn collect_file_details(pool: &sqlx::SqlitePool) -> ArchiveResult<Vec<FileDetail>> {
let rows = sqlx::query(
r"
SELECT
c.file_path,
c.file_size,
c.last_verified_at,
COUNT(fc.id) as verification_count,
SUM(CASE WHEN fc.status = 'fail' THEN 1 ELSE 0 END) as failure_count
FROM checksums c
LEFT JOIN fixity_checks fc ON c.file_path = fc.file_path
GROUP BY c.file_path
ORDER BY c.last_verified_at DESC
LIMIT 100
",
)
.fetch_all(pool)
.await?;
let mut details = Vec::new();
for row in rows {
let file_path: String = row.get("file_path");
let file_size: i64 = row.get("file_size");
let last_verified_str: Option<String> = row.get("last_verified_at");
let verification_count: i64 = row.get("verification_count");
let failure_count: i64 = row.get("failure_count");
let last_verified = if let Some(s) = last_verified_str {
Some(
DateTime::parse_from_rfc3339(&s)
.map_err(|e| ArchiveError::Database(sqlx::Error::Decode(Box::new(e))))?
.with_timezone(&Utc),
)
} else {
None
};
let status = if failure_count > 0 {
FileStatus::Failed
} else if last_verified.is_none() {
FileStatus::NeedsVerification
} else {
FileStatus::Healthy
};
details.push(FileDetail {
file_path,
file_size: file_size as u64,
last_verified,
verification_count: verification_count as u32,
failure_count: failure_count as u32,
status,
});
}
Ok(details)
}
fn generate_html_report(report: &VerificationReport) -> ArchiveResult<String> {
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OxiMedia Archive Verification Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
h1 {{ color: #333; }}
h2 {{ color: #666; border-bottom: 2px solid #ddd; padding-bottom: 5px; }}
table {{ border-collapse: collapse; width: 100%; margin: 20px 0; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #f2f2f2; }}
.summary {{ background-color: #f9f9f9; padding: 15px; border-radius: 5px; }}
.metric {{ display: inline-block; margin: 10px 20px 10px 0; }}
.metric-label {{ font-weight: bold; }}
.alert-critical {{ background-color: #ffcccc; }}
.alert-error {{ background-color: #ffddcc; }}
.alert-warning {{ background-color: #ffffcc; }}
.alert-info {{ background-color: #ccffff; }}
.status-healthy {{ color: green; }}
.status-failed {{ color: red; }}
.status-needs-check {{ color: orange; }}
</style>
</head>
<body>
<h1>OxiMedia Archive Verification Report</h1>
<p>Generated: {}</p>
<div class="summary">
<h2>Summary</h2>
<div class="metric"><span class="metric-label">Total Files:</span> {}</div>
<div class="metric"><span class="metric-label">Total Size:</span> {} bytes</div>
<div class="metric"><span class="metric-label">Verified:</span> {}</div>
<div class="metric"><span class="metric-label">Failed:</span> {}</div>
<div class="metric"><span class="metric-label">Quarantined:</span> {}</div>
</div>
<h2>Integrity Metrics</h2>
<div class="summary">
<div class="metric"><span class="metric-label">Overall Health:</span> {:.2}%</div>
<div class="metric"><span class="metric-label">Checksum Coverage:</span> {:.2}%</div>
<div class="metric"><span class="metric-label">Fixity Compliance:</span> {:.2}%</div>
<div class="metric"><span class="metric-label">Quarantine Rate:</span> {:.2}%</div>
</div>
<h2>Alerts</h2>
<table>
<tr>
<th>Type</th>
<th>Severity</th>
<th>Message</th>
<th>File</th>
</tr>
{}
</table>
<h2>Recent Events</h2>
<table>
<tr>
<th>Date</th>
<th>Type</th>
<th>File</th>
<th>Outcome</th>
</tr>
{}
</table>
</body>
</html>"#,
report.generated_at.to_rfc3339(),
report.summary.total_files,
report.summary.total_size,
report.summary.verified_files,
report.summary.failed_files,
report.summary.quarantined_files,
report.integrity_metrics.overall_health * 100.0,
report.integrity_metrics.checksum_coverage * 100.0,
report.integrity_metrics.fixity_compliance * 100.0,
report.integrity_metrics.quarantine_rate * 100.0,
generate_alert_rows(&report.alerts),
generate_event_rows(&report.recent_events),
);
Ok(html)
}
fn generate_alert_rows(alerts: &[Alert]) -> String {
alerts
.iter()
.map(|alert| {
let severity_class = match alert.severity {
AlertSeverity::Critical => "alert-critical",
AlertSeverity::Error => "alert-error",
AlertSeverity::Warning => "alert-warning",
AlertSeverity::Info => "alert-info",
};
format!(
r#"<tr class="{}"><td>{:?}</td><td>{:?}</td><td>{}</td><td>{}</td></tr>"#,
severity_class,
alert.alert_type,
alert.severity,
alert.message,
alert.file_path.as_ref().unwrap_or(&String::new())
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn generate_event_rows(events: &[RecentEvent]) -> String {
events
.iter()
.map(|event| {
format!(
r"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
event.event_date.format("%Y-%m-%d %H:%M:%S"),
event.event_type,
event.file_path,
event.outcome
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn generate_json_report(report: &VerificationReport) -> ArchiveResult<String> {
serde_json::to_string_pretty(report)
.map_err(|e| ArchiveError::Report(format!("Failed to serialize JSON: {e}")))
}
fn generate_csv_report(report: &VerificationReport) -> ArchiveResult<String> {
let mut wtr = csv::Writer::from_writer(vec![]);
wtr.write_record([
"File Path",
"File Size",
"Last Verified",
"Verification Count",
"Failure Count",
"Status",
])
.map_err(|e| ArchiveError::Report(format!("CSV write error: {e}")))?;
for detail in &report.file_details {
wtr.write_record([
&detail.file_path,
&detail.file_size.to_string(),
&detail
.last_verified
.map(|dt| dt.to_rfc3339())
.unwrap_or_default(),
&detail.verification_count.to_string(),
&detail.failure_count.to_string(),
&format!("{:?}", detail.status),
])
.map_err(|e| ArchiveError::Report(format!("CSV write error: {e}")))?;
}
wtr.flush()
.map_err(|e| ArchiveError::Report(format!("CSV flush error: {e}")))?;
String::from_utf8(
wtr.into_inner()
.map_err(|e| ArchiveError::Report(format!("CSV error: {e}")))?,
)
.map_err(|e| ArchiveError::Report(format!("UTF-8 error: {e}")))
}
fn generate_text_report(report: &VerificationReport) -> ArchiveResult<String> {
let mut text = String::new();
text.push_str("OxiMedia Archive Verification Report\n");
text.push_str("=====================================\n\n");
text.push_str(&format!(
"Generated: {}\n\n",
report.generated_at.to_rfc3339()
));
text.push_str("Summary\n");
text.push_str("-------\n");
text.push_str(&format!("Total Files: {}\n", report.summary.total_files));
text.push_str(&format!(
"Total Size: {} bytes\n",
report.summary.total_size
));
text.push_str(&format!("Verified: {}\n", report.summary.verified_files));
text.push_str(&format!("Failed: {}\n", report.summary.failed_files));
text.push_str(&format!(
"Quarantined: {}\n\n",
report.summary.quarantined_files
));
text.push_str("Integrity Metrics\n");
text.push_str("-----------------\n");
text.push_str(&format!(
"Overall Health: {:.2}%\n",
report.integrity_metrics.overall_health * 100.0
));
text.push_str(&format!(
"Checksum Coverage: {:.2}%\n",
report.integrity_metrics.checksum_coverage * 100.0
));
text.push_str(&format!(
"Fixity Compliance: {:.2}%\n",
report.integrity_metrics.fixity_compliance * 100.0
));
text.push_str(&format!(
"Quarantine Rate: {:.2}%\n\n",
report.integrity_metrics.quarantine_rate * 100.0
));
text.push_str("Alerts\n");
text.push_str("------\n");
for alert in &report.alerts {
text.push_str(&format!(
"[{:?}] {:?}: {} ({})\n",
alert.severity,
alert.alert_type,
alert.message,
alert.file_path.as_ref().unwrap_or(&"N/A".to_string())
));
}
Ok(text)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OaisComplianceReport {
pub report_date: DateTime<Utc>,
pub preservation_planning: PreservationPlanning,
pub ingest_compliance: IngestCompliance,
pub archival_storage: ArchivalStorage,
pub data_management: DataManagement,
pub access_compliance: AccessCompliance,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreservationPlanning {
pub format_monitoring: bool,
pub migration_planning: bool,
pub risk_assessment: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IngestCompliance {
pub checksum_verification: bool,
pub metadata_extraction: bool,
pub premis_events: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchivalStorage {
pub fixity_checking: bool,
pub redundancy: bool,
pub error_detection: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataManagement {
pub database_maintained: bool,
pub audit_trail: bool,
pub retention_policies: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessCompliance {
pub search_capability: bool,
pub retrieval_capability: bool,
pub delivery_mechanisms: bool,
}
#[allow(dead_code)]
pub async fn generate_oais_report(
_pool: &sqlx::SqlitePool,
output_path: &PathBuf,
) -> ArchiveResult<()> {
let report = OaisComplianceReport {
report_date: Utc::now(),
preservation_planning: PreservationPlanning {
format_monitoring: true,
migration_planning: false,
risk_assessment: true,
},
ingest_compliance: IngestCompliance {
checksum_verification: true,
metadata_extraction: true,
premis_events: true,
},
archival_storage: ArchivalStorage {
fixity_checking: true,
redundancy: false,
error_detection: true,
},
data_management: DataManagement {
database_maintained: true,
audit_trail: true,
retention_policies: false,
},
access_compliance: AccessCompliance {
search_capability: true,
retrieval_capability: true,
delivery_mechanisms: false,
},
};
let json = serde_json::to_string_pretty(&report)
.map_err(|e| ArchiveError::Report(format!("Failed to serialize OAIS report: {e}")))?;
fs::write(output_path, json).await?;
info!(
"OAIS compliance report generated: {}",
output_path.display()
);
Ok(())
}