use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use chrono::{TimeZone, Utc};
use crate::core::pipeline::executor::PipelineSummary;
use crate::core::recipe::{
DetectionReport, FileClassification, Recipe, TransformReport as RecipeTransformReport,
};
use crate::core::report::models::{
ChangedFile, ExecutionMetadata, FailureEntry, FileAnalysis, RecipeSummary, RecipeTiming,
RollbackInfo, SkippedFile, TransformReport,
};
pub struct ReportGenerator {
report: TransformReport,
}
impl ReportGenerator {
pub fn new(execution: ExecutionMetadata) -> Self {
Self {
report: TransformReport::new(uuid::Uuid::new_v4().to_string(), execution),
}
}
pub fn add_recipe_results<R: Recipe + ?Sized>(
&mut self,
recipe: &R,
detection: &DetectionReport,
transform: &RecipeTransformReport,
timing: RecipeTiming,
) {
let metadata = recipe.metadata();
let summary = RecipeSummary {
name: metadata.name.to_string(),
description: metadata.description.to_string(),
extensions: metadata
.supported_extensions
.iter()
.map(|s| s.to_string())
.collect(),
files_processed: detection.analyses.len(),
files_changed: transform.changed_files.len(),
files_skipped: transform.skipped_files.len(),
unsupported_patterns: transform.unsupported_patterns.len(),
parse_failures: detection.failed_files.len(),
execution_duration_ms: timing.total_ms,
};
self.report.recipes.push(summary);
for analysis in &detection.analyses {
let file_analysis = FileAnalysis {
path: analysis.path.clone(),
patterns: analysis.detected_patterns.clone(),
confidence: analysis.confidence_score,
safety: format!("{:?}", analysis.classification),
is_safe: analysis.classification == FileClassification::Safe,
tags: analysis.tags.clone(),
};
self.report.files.analyses.push(file_analysis);
if analysis.classification == FileClassification::Safe {
self.report.safety.safe_count += 1;
} else {
self.report.safety.risky_count += 1;
}
}
for skipped in &detection.skipped_files {
self.report.files.skipped.push(SkippedFile {
path: skipped.clone(),
reason: "Parse failed".to_string(),
});
}
for skipped in &transform.skipped_files {
self.report.files.skipped.push(SkippedFile {
path: skipped.path.clone(),
reason: skipped.reason.clone(),
});
}
for changed in &transform.changed_files {
self.report.files.changed.push(ChangedFile {
path: changed.clone(),
lines_added: 1,
lines_removed: 1,
preview: None,
});
}
self.report
.timing
.per_recipe
.insert(metadata.name.to_string(), timing);
self.report.files.total = detection.total_files;
self.report.files.parseable = detection.parseable_files;
}
pub fn add_pipeline_results(&mut self, summary: &PipelineSummary) {
for (name, ms) in &summary.timings {
let timing =
self.report
.timing
.per_recipe
.entry(name.clone())
.or_insert(RecipeTiming {
detect_ms: 0,
transform_ms: 0,
total_ms: 0,
});
timing.total_ms = *ms;
}
for failed in &summary.stages_failed {
self.report.failures.push(FailureEntry {
recipe: failed.clone(),
stage: "transform".to_string(),
error: "Recipe transformation failed".to_string(),
affected_files: Vec::new(),
});
}
self.report.timing.total_ms = summary.timings.iter().map(|(_, ms)| ms).sum();
}
#[allow(dead_code)]
pub fn add_safety_warning(&mut self, warning: String) {
self.report.safety.warnings.push(warning);
}
#[allow(dead_code)]
pub fn add_recommendation(&mut self, recommendation: String) {
self.report.safety.recommendations.push(recommendation);
}
pub fn set_rollback_session(&mut self, session_id: String, created_at: i64, file_count: usize) {
self.report.rollback_session = Some(RollbackInfo {
session_id,
created_at,
file_count,
});
}
#[allow(dead_code)]
pub fn build(self) -> TransformReport {
self.report
}
pub fn report(&self) -> &TransformReport {
&self.report
}
pub fn report_id(&self) -> &str {
&self.report.id
}
pub fn write_json(&self, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(&self.report)
.context("Failed to serialize report to JSON")?;
fs::write(path, json).context("Failed to write JSON report")?;
Ok(())
}
pub fn write_markdown(&self, path: &Path) -> Result<()> {
let md = self.generate_markdown();
fs::write(path, md).context("Failed to write markdown report")?;
Ok(())
}
fn generate_markdown(&self) -> String {
let mut md = String::new();
md.push_str("# 🛠️ Morph Transformation Report\n\n");
md.push_str("> [!IMPORTANT]\n");
md.push_str(&format!("> **Report ID:** `{}` \n", self.report.id));
md.push_str(&format!(
"> **Generated At:** {} UTC \n",
Utc.timestamp_opt(self.report.timestamp, 0)
.unwrap()
.format("%Y-%m-%d %H:%M:%S")
));
md.push_str(&format!("> **Execution Mode:** `{}`\n\n", self.report.execution.mode));
md.push_str("## 📊 Executive Overview\n\n");
md.push_str("| Metric | Value | Detail |\n");
md.push_str("| :--- | :--- | :--- |\n");
md.push_str(&format!("| **Total Files** | `{}` | Total files scanned in workspace |\n", self.report.files.total));
md.push_str(&format!("| **Parseable Files** | `{}` | Successfully parsed AST |\n", self.report.files.parseable));
md.push_str(&format!("| **Files Changed** | `{}` | Modified on disk |\n", self.report.files.changed.len()));
md.push_str(&format!("| **Files Skipped** | `{}` | Skipped due to lack of changes or safety |\n", self.report.files.skipped.len()));
md.push_str(&format!("| **Recipes Applied** | `{}` | Active migration rules |\n", self.report.recipes.len()));
md.push_str(&format!("| **Execution Duration** | `{}ms` | Total processing duration |\n", self.report.timing.total_ms));
md.push_str(&format!("| **Allow Risky Options** | `{}` | Whether riskier transforms were allowed |\n", self.report.execution.allow_risky));
md.push_str(&format!("| **Strict Mode Enforcement** | `{}` | Halt on first error |\n", self.report.execution.strict));
md.push('\n');
let mut unique_warnings = std::collections::HashSet::new();
let mut deduplicated_warnings = Vec::new();
for warning in &self.report.safety.warnings {
let trimmed = warning.trim().to_string();
if !trimmed.is_empty() && unique_warnings.insert(trimmed.clone()) {
deduplicated_warnings.push(trimmed);
}
}
if !deduplicated_warnings.is_empty() || !self.report.safety.recommendations.is_empty() {
md.push_str("## 🚨 Safety & Verification Analysis\n\n");
if !deduplicated_warnings.is_empty() {
md.push_str("> [!WARNING]\n");
md.push_str("> **Critical Safety Alerts:**\n");
for warning in &deduplicated_warnings {
md.push_str(&format!("> - {}\n", warning));
}
md.push('\n');
}
if !self.report.safety.recommendations.is_empty() {
md.push_str("> [!TIP]\n");
md.push_str("> **Recommended Actions for Modernization:**\n");
for rec in &self.report.safety.recommendations {
md.push_str(&format!("> - {}\n", rec));
}
md.push('\n');
}
}
let risky_files: Vec<&FileAnalysis> = self.report.files.analyses.iter()
.filter(|f| !f.is_safe || f.safety == "Risky" || f.confidence < 80)
.collect();
if !risky_files.is_empty() {
md.push_str("## ⚠️ Risky Files & Confidence Check (High Priority)\n\n");
md.push_str("The following files require manual review due to containing complex syntactic structures or low migration confidence.\n\n");
md.push_str("| File Path | Safety Rating | Confidence Score | Detected Risk Patterns |\n");
md.push_str("| :--- | :--- | :--- | :--- |\n");
let mut sorted_risky = risky_files.clone();
sorted_risky.sort_by_key(|f| f.confidence);
for file in sorted_risky {
let safety_badge = if file.confidence < 50 {
"🔴 High Risk"
} else if file.confidence < 80 {
"🟡 Moderate Risk"
} else {
"🟢 Safe"
};
md.push_str(&format!(
"| `{}` | {} | `{}%` | {} |\n",
file.path.display(),
safety_badge,
file.confidence,
if file.patterns.is_empty() { "Unknown".to_string() } else { file.patterns.join(", ") }
));
}
md.push('\n');
}
if !self.report.failures.is_empty() {
md.push_str("## ❌ Parse Failures & Execution Exceptions (High Priority)\n\n");
md.push_str("Errors encountered during code scanning or transform execution. Please inspect syntax compatibility.\n\n");
let mut grouped_failures: std::collections::HashMap<String, Vec<&FailureEntry>> = std::collections::HashMap::new();
for failure in &self.report.failures {
grouped_failures.entry(failure.error.clone()).or_default().push(failure);
}
for (error, list) in grouped_failures {
md.push_str(&format!("> [!CAUTION]\n"));
md.push_str(&format!("> **Category: {}** \n", error));
for f in list {
md.push_str(&format!("> - Recipe: `{}` | Stage: `{}`\n", f.recipe, f.stage));
}
md.push('\n');
}
}
if !self.report.files.skipped.is_empty() {
md.push_str("## ⚡ Skipped Transforms (Medium Priority)\n\n");
md.push_str("Files identified but skipped during execution.\n\n");
let mut grouped_skipped: std::collections::HashMap<String, Vec<&SkippedFile>> = std::collections::HashMap::new();
for skipped in &self.report.files.skipped {
grouped_skipped.entry(skipped.reason.clone()).or_default().push(skipped);
}
for (reason, files) in grouped_skipped {
md.push_str(&format!("### Reason: {}\n\n", reason));
md.push_str("| File Path |\n");
md.push_str("| :--- |\n");
for f in files.iter().take(50) {
md.push_str(&format!("| `{}` |\n", f.path.display()));
}
if files.len() > 50 {
md.push_str(&format!("| *... and {} more files* |\n", files.len() - 50));
}
md.push('\n');
}
}
let safe_files: Vec<&FileAnalysis> = self.report.files.analyses.iter()
.filter(|f| f.is_safe && f.safety != "Risky" && f.confidence >= 80)
.collect();
if !safe_files.is_empty() {
md.push_str("## 🟢 Safe / Completed Files (Low Priority)\n\n");
md.push_str("| File Path | Confidence | Tags |\n");
md.push_str("| :--- | :--- | :--- |\n");
for file in safe_files.iter().take(50) {
md.push_str(&format!(
"| `{}` | `{}%` | {} |\n",
file.path.display(),
file.confidence,
file.tags.join(", ")
));
}
if safe_files.len() > 50 {
md.push_str(&format!("| *... and {} more files* | | |\n", safe_files.len() - 50));
}
md.push('\n');
}
if !self.report.recipes.is_empty() {
md.push_str("## 📜 Active Recipes Applied\n\n");
for recipe in &self.report.recipes {
md.push_str(&format!("### {} \n", recipe.name));
md.push_str(&format!("*{}*\n\n", recipe.description));
md.push_str(&format!("- **Supported Extensions:** `{}`\n", recipe.extensions.join(", ")));
md.push_str(&format!("- **Files Changed:** `{}` | **Files Skipped:** `{}`\n\n", recipe.files_changed, recipe.files_skipped));
}
}
if let Some(ref rollback) = self.report.rollback_session {
md.push_str("## 🔄 Rollback & Recovery Info\n\n");
md.push_str(&format!("- **Session ID:** `{}`\n", rollback.session_id));
md.push_str(&format!("- **Recovery Files:** `{}`\n", rollback.file_count));
md.push_str(&format!("- **Command to Restore:** `morph rollback {}`\n\n", rollback.session_id));
}
md
}
}
#[allow(dead_code)]
pub fn generate_report_id() -> String {
uuid::Uuid::new_v4().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_report_generator_new() {
let execution = ExecutionMetadata {
command: "morph run".to_string(),
version: "0.1.0".to_string(),
mode: "dry-run".to_string(),
project_root: PathBuf::from("/tmp"),
target_path: PathBuf::from("/tmp/src"),
allow_risky: false,
strict: false,
};
let generator = ReportGenerator::new(execution);
assert!(!generator.report.id.is_empty());
}
#[test]
fn test_report_generator_add_warning() {
let execution = ExecutionMetadata {
command: "morph run".to_string(),
version: "0.1.0".to_string(),
mode: "write".to_string(),
project_root: PathBuf::from("/tmp"),
target_path: PathBuf::from("/tmp/src"),
allow_risky: true,
strict: false,
};
let mut generator = ReportGenerator::new(execution);
generator.add_safety_warning("Test warning".to_string());
assert_eq!(generator.report.safety.warnings.len(), 1);
}
#[test]
fn test_markdown_generation() {
let execution = ExecutionMetadata {
command: "morph run".to_string(),
version: "0.1.0".to_string(),
mode: "dry-run".to_string(),
project_root: PathBuf::from("/tmp"),
target_path: PathBuf::from("/tmp/src"),
allow_risky: false,
strict: false,
};
let generator = ReportGenerator::new(execution);
let md = generator.generate_markdown();
assert!(md.contains("# 🛠️ Morph Transformation Report"));
assert!(md.contains("## 📊 Executive Overview"));
}
}