use std::path::{Path, PathBuf}; use crate::io::xref::{XrefInfo, XrefStatus}; use crate::scene::OpenTimings; #[cfg(not(target_arch = "wasm32"))] use sha2::{Digest, Sha256}; const REPORT_SCHEMA_VERSION: u16 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RecoveryStatus { Recovered, Failed, } #[derive(Debug, Clone)] pub struct RecoveryReport { pub report_id: String, pub status: RecoveryStatus, pub tab_id: Option, pub file_name: String, pub source_path: Option, pub size_bytes: u64, pub source_sha256: Option, pub read_stats: Option, pub entities_scanned: usize, pub parser_errors_recovered: usize, pub diagnostics: Vec<(String, String)>, pub references_checked: usize, pub references_loaded: usize, pub references_recovered: usize, pub references_missing: usize, pub references_failed: usize, pub references_skipped: usize, pub reference_details: Vec<(String, String, String, Option)>, pub reference_stats: Vec<(String, codec::ReadStats)>, pub timings: OpenTimings, pub total_ms: u32, pub failure_phase: Option, pub error: Option, pub save_as_required: bool, pub log_path: Option, pub log_error: Option, pub created_unix_seconds: u64, } impl RecoveryReport { pub fn recovered( tab_id: u64, path: &Path, size_bytes: u64, source_sha256: Option, read_stats: Option, entities_scanned: usize, references: &[XrefInfo], notifications: &codec::notification::NotificationCollection, save_as_required: bool, timings: OpenTimings, total_ms: u32, ) -> Self { let references_loaded = references .iter() .filter(|item| matches!(item.status, XrefStatus::Loaded | XrefStatus::Recovered)) .count(); let references_recovered = references .iter() .filter(|item| item.status == XrefStatus::Recovered) .count(); let references_missing = references .iter() .filter(|item| item.status == XrefStatus::NotFound) .count(); let references_failed = references .iter() .filter(|item| item.status == XrefStatus::Failed) .count(); let references_skipped = references .iter() .filter(|item| item.status == XrefStatus::Unloaded) .count(); let reference_details = references .iter() .enumerate() .map(|(index, item)| { let status = match item.status { XrefStatus::Loaded => "loaded", XrefStatus::Recovered => "recovered", XrefStatus::Failed => "failed", XrefStatus::NotFound => "not found", XrefStatus::Unloaded => "skipped", }; ( format!("reference-{:02}", index + 1), private_path_label(&item.path), status.to_string(), item.source_sha256.clone(), ) }) .collect(); let reference_stats = references .iter() .enumerate() .filter_map(|(index, item)| { let mut stats = item.read_stats.clone()?; for diagnostic in &mut stats.diagnostics { diagnostic.message = redact_known_paths(&diagnostic.message, path, references); } Some((format!("reference-{:02}", index + 1), stats)) }) .collect(); let parser_errors_recovered = read_stats .as_ref() .map(|stats| stats.recovered_errors) .unwrap_or_else(|| { notifications .iter() .filter(|item| { item.notification_type == codec::notification::NotificationType::Error }) .count() }); let mut diagnostics: Vec<(String, String)> = notifications .iter() .map(|item| { ( item.notification_type.to_string(), redact_known_paths(&item.message, path, references), ) }) .collect(); if notifications.omitted_count() > 0 { diagnostics.insert( 0, ( "truncated".to_string(), format!( "{} additional parser notifications were omitted after the safety limit", notifications.omitted_count() ), ), ); } for (index, reference) in references.iter().enumerate() { diagnostics.extend(reference.diagnostics.iter().map(|message| { ( format!("Reference {:02}", index + 1), redact_known_paths(message, path, references), ) })); } let created_unix_seconds = unix_seconds(); Self { report_id: report_id(source_sha256.as_deref(), created_unix_seconds), status: RecoveryStatus::Recovered, tab_id: Some(tab_id), file_name: display_name(path), source_path: Some(path.to_path_buf()), size_bytes, source_sha256, read_stats, entities_scanned, parser_errors_recovered, diagnostics, references_checked: references.len(), references_loaded, references_recovered, references_missing, references_failed, references_skipped, reference_details, reference_stats, timings, total_ms, failure_phase: None, error: None, save_as_required, log_path: None, log_error: None, created_unix_seconds, } } pub fn failed( path: Option, file_name: String, size_bytes: u64, source_sha256: Option, read_stats: Option, failure_phase: String, error: String, total_ms: u32, ) -> Self { let created_unix_seconds = unix_seconds(); Self { report_id: report_id(source_sha256.as_deref(), created_unix_seconds), status: RecoveryStatus::Failed, tab_id: None, file_name, source_path: path, size_bytes, source_sha256, parser_errors_recovered: read_stats .as_ref() .map(|stats| stats.recovered_errors) .unwrap_or(0), read_stats, entities_scanned: 0, diagnostics: Vec::new(), references_checked: 0, references_loaded: 0, references_recovered: 0, references_missing: 0, references_failed: 0, references_skipped: 0, reference_details: Vec::new(), reference_stats: Vec::new(), timings: OpenTimings::default(), total_ms, failure_phase: Some(failure_phase), error: Some(error), save_as_required: false, log_path: None, log_error: None, created_unix_seconds, } } pub fn issues_found(&self) -> usize { self.parser_errors_recovered .saturating_add(self.references_recovered) .saturating_add(self.references_failed) .saturating_add(self.references_missing) .saturating_add(usize::from(self.error.is_some())) } pub fn log_text(&self) -> String { let mut lines = Vec::with_capacity(24); lines.push("Open CAD Studio drawing recovery report".to_string()); lines.push(format!("Report schema: {}", REPORT_SCHEMA_VERSION)); lines.push(format!("Report ID: {}", self.report_id)); lines.push(format!("Application version: {}", env!("OCS_APP_VERSION"))); lines.push(format!("Application build: {}", env!("OCS_FULL_VERSION"))); lines.push(format!("Application revision: {}", env!("OCS_GIT_REV"))); lines.push(format!("Build profile: {}", env!("OCS_BUILD_PROFILE"))); lines.push(format!("Build features: {}", env!("OCS_BUILD_FEATURES"))); lines.push(format!("Reader version: {}", codec::VERSION)); lines.push(format!("Reader revision: {}", reader_revision())); lines.push(format!("Platform: {}", diagnostic_platform())); lines.push(format!( "Created (Unix seconds): {}", self.created_unix_seconds )); lines.push(format!( "File: {}", private_file_label(&self.file_name, self.source_sha256.as_deref()) )); lines.push(format!("Size: {} bytes", self.size_bytes)); lines.push(format!( "Source SHA-256: {}", self.source_sha256.as_deref().unwrap_or("unavailable") )); lines.push(format!( "Result: {}", match self.status { RecoveryStatus::Recovered => "opened with a recovery report", RecoveryStatus::Failed => "could not be opened", } )); lines.push(String::new()); lines.push("Summary".to_string()); if let Some(stats) = &self.read_stats { lines.push(format!( "Format: {}", stats .source_format .map(|format| format.to_string()) .unwrap_or_else(|| "unknown".to_string()) )); lines.push(format!("Format version: {}", stats.source_version)); lines.push(format!( "Maintenance version: {}", stats.maintenance_version )); lines.push(format!("Recovery mode: {}", stats.recovery_mode)); lines.push(format!("Stream completed: {}", stats.stream_completed)); lines.push(format!("Source sections: {}", stats.source_sections)); lines.push(format!("Observed source records: {}", stats.source_records)); lines.push(format!( "Successfully decoded source records: {}", stats.decoded_source_records )); lines.push(format!( "Known skipped source records: {}", stats.skipped_source_records )); lines.push(format!("Output entities: {}", stats.output_entities)); lines.push(format!("Output objects: {}", stats.output_objects)); lines.push(format!( "Output table records: {}", stats.output_table_records )); } lines.push(format!("Entities scanned: {}", self.entities_scanned)); lines.push(format!( "Parser errors recovered: {}", self.parser_errors_recovered )); lines.push(format!("References checked: {}", self.references_checked)); lines.push(format!("References loaded: {}", self.references_loaded)); lines.push(format!( "References recovered: {}", self.references_recovered )); lines.push(format!( "References unavailable: {}", self.references_missing )); lines.push(format!("References failed: {}", self.references_failed)); lines.push(format!("References skipped: {}", self.references_skipped)); lines.push(format!( "Save as new file required: {}", if self.save_as_required { "yes" } else { "no" } )); if !self.reference_details.is_empty() { lines.push(String::new()); lines.push("References".to_string()); for (name, path, status, source_sha256) in &self.reference_details { lines.push(format!( "[{status}] {name}: {path}; SHA-256={}", source_sha256.as_deref().unwrap_or("unavailable") )); } } if !self.reference_stats.is_empty() { lines.push(String::new()); lines.push("Referenced source statistics".to_string()); for (name, stats) in &self.reference_stats { lines.push(format!( "{name}: format={} version={} recovery={} completed={} observed-records={} decoded={} known-skipped={}", stats .source_format .map(|format| format.to_string()) .unwrap_or_else(|| "unknown".to_string()), stats.source_version, stats.recovery_mode, stats.stream_completed, stats.source_records, stats.decoded_source_records, stats.skipped_source_records, )); for diagnostic in stats.diagnostics.iter().take(50) { lines.push(format!("{name} {}", structured_diagnostic_line(diagnostic))); } } } lines.push(String::new()); lines.push("Timing".to_string()); lines.push(format!("Parse: {} ms", self.timings.parse_ms)); lines.push(format!("Validation: {} ms", self.timings.purge_ms)); lines.push(format!("References: {} ms", self.timings.xref_ms)); lines.push(format!("Scene caches: {} ms", self.timings.caches_ms)); lines.push(format!("Geometry prep: {} ms", self.timings.finalize_ms)); lines.push(format!("Total: {} ms", self.total_ms)); if !self.diagnostics.is_empty() { lines.push(String::new()); lines.push("Diagnostics".to_string()); for (kind, message) in self.diagnostics.iter().take(200) { lines.push(format!("[{kind}] {message}")); } if self.diagnostics.len() > 200 { lines.push(format!( "[truncated] {} additional diagnostics omitted", self.diagnostics.len() - 200 )); } } if let Some(stats) = &self.read_stats { if !stats.diagnostics.is_empty() { lines.push(String::new()); lines.push("Structured reader diagnostics".to_string()); for diagnostic in stats.diagnostics.iter().take(100) { lines.push(redact_source_path( &structured_diagnostic_line(diagnostic), self.source_path.as_deref(), )); } if stats.diagnostics.len() > 100 { lines.push(format!( "[truncated] {} additional diagnostics omitted", stats.diagnostics.len() - 100 )); } } } if let Some(error) = &self.error { lines.push(String::new()); lines.push("Failure".to_string()); if let Some(phase) = &self.failure_phase { lines.push(format!("Phase: {phase}")); } lines.push(redact_source_path(error, self.source_path.as_deref())); } lines.push(String::new()); lines.push("The source file was not modified by this operation.".to_string()); if self.save_as_required { lines.push( "Save the repaired drawing as a new file before continuing work.".to_string(), ); } lines.push(String::new()); lines.join("\n") } pub fn suggested_download_name(&self) -> String { format!("drawing_recovery_{}.log", safe_stem(&self.report_id)) } #[cfg(not(target_arch = "wasm32"))] pub fn persist(&mut self) { match write_report(self) { Ok(path) => self.log_path = Some(path), Err(error) => self.log_error = Some(error), } } #[cfg(target_arch = "wasm32")] pub fn persist(&mut self) {} } fn display_name(path: &Path) -> String { path.file_name() .map(|value| value.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()) } #[cfg(not(target_arch = "wasm32"))] pub fn sha256_file(path: &Path) -> Result { use std::io::Read; let mut file = std::fs::File::open(path).map_err(|error| error.to_string())?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 128 * 1024]; loop { let count = file.read(&mut buffer).map_err(|error| error.to_string())?; if count == 0 { break; } hasher.update(&buffer[..count]); } Ok(hex_digest(hasher.finalize())) } #[cfg(target_arch = "wasm32")] pub fn sha256_file(_path: &Path) -> Result { Err("filesystem hashing is unavailable on this target".to_string()) } #[cfg(not(target_arch = "wasm32"))] fn hex_digest(bytes: impl AsRef<[u8]>) -> String { let bytes = bytes.as_ref(); let mut output = String::with_capacity(bytes.len() * 2); for byte in bytes { use std::fmt::Write; let _ = write!(output, "{byte:02x}"); } output } fn report_id(source_sha256: Option<&str>, created_unix_seconds: u64) -> String { let fingerprint = source_sha256 .and_then(|value| value.get(..12)) .unwrap_or("nohash"); format!("R2-{created_unix_seconds}-{fingerprint}") } fn private_file_label(file_name: &str, source_sha256: Option<&str>) -> String { let extension = Path::new(file_name) .extension() .and_then(|value| value.to_str()) .filter(|value| value.chars().all(|ch| ch.is_ascii_alphanumeric())) .map(|value| format!(".{value}")) .unwrap_or_default(); let fingerprint = source_sha256 .and_then(|value| value.get(..12)) .unwrap_or("redacted"); format!("drawing-{fingerprint}{extension}") } fn private_path_label(value: &str) -> String { let normalized = value.replace('\\', "/"); let extension = Path::new(&normalized) .extension() .and_then(|extension| extension.to_str()) .filter(|extension| extension.chars().all(|ch| ch.is_ascii_alphanumeric())) .map(|extension| format!(".{extension}")) .unwrap_or_default(); format!("reference-redacted{extension}") } fn redact_known_paths(message: &str, source: &Path, references: &[XrefInfo]) -> String { let mut redacted = redact_source_path(message, Some(source)); for reference in references { let normalized = reference.path.replace('\\', "/"); let basename = Path::new(&normalized) .file_name() .and_then(|value| value.to_str()) .unwrap_or_default(); for candidate in [&reference.path, &normalized, basename] { if !candidate.is_empty() { redacted = redacted.replace(candidate, ""); } } if !reference.name.is_empty() { redacted = redacted.replace(&reference.name, ""); } } redacted } fn redact_source_path(message: &str, source: Option<&Path>) -> String { let Some(source) = source else { return message.to_string(); }; let mut redacted = message.replace(&source.to_string_lossy().to_string(), ""); if let Some(file_name) = source.file_name().and_then(|value| value.to_str()) { redacted = redacted.replace(file_name, ""); } redacted } fn structured_diagnostic_line(diagnostic: &codec::ReadDiagnostic) -> String { let section = diagnostic.section.as_deref().unwrap_or("-"); let offset = diagnostic .source_offset .map(|value| format!("0x{value:X}")) .unwrap_or_else(|| "-".to_string()); let offset_basis = diagnostic.source_offset_basis.as_deref().unwrap_or("-"); let line = diagnostic .source_line .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()); let handle = diagnostic .record_handle .map(|value| format!("0x{value:X}")) .unwrap_or_else(|| "-".to_string()); let record_type = diagnostic.record_type.as_deref().unwrap_or("-"); format!( "[code={} stage={} section={} offset={} offset-basis={} line={} handle={} type={}] {}", diagnostic.code, diagnostic.stage, section, offset, offset_basis, line, handle, record_type, diagnostic.message ) } fn reader_revision() -> String { include_str!("../../Cargo.toml") .lines() .find(|line| line.trim_start().starts_with("opencadcodec = { git =")) .and_then(|line| line.split("rev = \"").nth(1)) .and_then(|value| value.split('"').next()) .filter(|value| !value.is_empty()) .unwrap_or("unknown") .to_string() } fn diagnostic_platform() -> String { format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH) } #[cfg(not(target_arch = "wasm32"))] fn unix_seconds() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0) } #[cfg(target_arch = "wasm32")] fn unix_seconds() -> u64 { (js_sys::Date::now() / 1000.0) as u64 } fn safe_stem(value: &str) -> String { let filtered: String = value .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { ch } else { '_' } }) .take(80) .collect(); if filtered.is_empty() { "drawing".to_string() } else { filtered } } #[cfg(not(target_arch = "wasm32"))] fn write_report(report: &RecoveryReport) -> Result { let file_name = report.suggested_download_name(); let body = report.log_text(); let mut errors = Vec::new(); if let Some(directory) = crate::config::config_dir().map(|path| path.join("recovery_logs")) { if let Err(error) = std::fs::create_dir_all(&directory) { errors.push(format!( "Could not create private recovery-log directory: {error}" )); } else { match write_unique(&directory, &file_name, &body) { Ok(path) => return Ok(path), Err(error) => errors.push(error), } } } Err(if errors.is_empty() { "No private recovery-log directory was available".to_string() } else { errors.join("; ") }) } #[cfg(not(target_arch = "wasm32"))] fn write_unique(directory: &Path, file_name: &str, body: &str) -> Result { use std::io::Write; let stem = Path::new(file_name) .file_stem() .map(|value| value.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing_recovery".to_string()); for suffix in 0..100u8 { let candidate = if suffix == 0 { directory.join(file_name) } else { directory.join(format!("{stem}_{suffix}.log")) }; let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } match options.open(&candidate) { Ok(mut file) => { file.write_all(body.as_bytes()) .map_err(|error| format!("Could not write recovery log: {error}"))?; return Ok(candidate); } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(format!("Could not create recovery log: {error}")), } } Err("Could not allocate a unique recovery-log name".to_string()) } #[cfg(test)] mod tests { use super::{report_id, RecoveryReport, RecoveryStatus}; #[test] fn report_id_includes_timestamp_and_source_fingerprint() { assert_eq!( report_id( Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), 1_777_777_777, ), "R2-1777777777-0123456789ab" ); assert_eq!(report_id(None, 1_777_777_777), "R2-1777777777-nohash"); } #[test] fn failed_report_uses_current_timestamp_in_report_id() { let report = RecoveryReport::failed( None, "invalid.dwg".to_string(), 42, None, None, "parse".to_string(), "invalid drawing".to_string(), 7, ); assert_eq!(report.status, RecoveryStatus::Failed); assert!(report.created_unix_seconds > 0); assert_eq!( report.report_id, report_id(report.source_sha256.as_deref(), report.created_unix_seconds) ); } }