use std::path::{Path, PathBuf};
use anyhow::Result;
use indicatif::ProgressBar;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RecipeMaturity {
Experimental,
Beta,
#[default]
Stable,
}
impl std::fmt::Display for RecipeMaturity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Experimental => write!(f, "experimental"),
Self::Beta => write!(f, "beta"),
Self::Stable => write!(f, "stable"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RecipeCategory {
Migration,
Cleanup,
Modernization,
Analysis,
Experimental,
}
impl std::fmt::Display for RecipeCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Migration => write!(f, "migration"),
Self::Cleanup => write!(f, "cleanup"),
Self::Modernization => write!(f, "modernization"),
Self::Analysis => write!(f, "analysis"),
Self::Experimental => write!(f, "experimental"),
}
}
}
impl std::str::FromStr for RecipeCategory {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"migration" => Ok(Self::Migration),
"cleanup" => Ok(Self::Cleanup),
"modernization" => Ok(Self::Modernization),
"analysis" => Ok(Self::Analysis),
"experimental" => Ok(Self::Experimental),
_ => Err(anyhow::anyhow!("Invalid category: {}", s)),
}
}
}
#[derive(Debug, Clone)]
pub struct RecipeMetadata {
pub name: &'static str,
pub description: &'static str,
pub supported_extensions: &'static [&'static str],
pub required_recipes: &'static [&'static str],
pub incompatible_recipes: &'static [&'static str],
pub should_run_before: &'static [&'static str],
pub should_run_after: &'static [&'static str],
pub maturity: RecipeMaturity,
pub category: RecipeCategory,
pub tags: &'static [&'static str],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectionFailure {
pub path: PathBuf,
pub error: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileClassification {
Safe,
Risky,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileAnalysis {
pub path: PathBuf,
pub detected_patterns: Vec<String>,
pub confidence_score: u8,
pub classification: FileClassification,
pub is_transform_safe: bool,
#[serde(default)]
pub tags: Vec<String>,
}
pub fn compute_file_tags(
path: &Path,
content: Option<&str>,
detected_patterns: &[String],
is_risky: bool,
is_ignored: bool,
) -> Vec<String> {
let mut tags = std::collections::BTreeSet::new();
if is_ignored {
tags.insert("ignored".to_string());
return tags.into_iter().collect();
}
let path_str = path.to_string_lossy().to_lowercase();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
if ext == "ts" || ext == "tsx" {
tags.insert("typescript".to_string());
}
if path_str.contains(".min.")
|| path_str.contains(".bundle.")
|| path_str.contains("/dist/")
|| path_str.contains("/build/")
|| path_str.contains("/node_modules/")
|| path_str.contains("package-lock.json")
|| path_str.contains("yarn.lock")
{
tags.insert("generated".to_string());
}
if is_risky
|| path_str.contains("risky")
|| detected_patterns.iter().any(|p| p.to_lowercase().contains("dynamic require") || p.to_lowercase().contains("eval"))
{
tags.insert("risky".to_string());
}
let mut has_react = ext == "jsx" || ext == "tsx";
if !has_react {
if let Some(c) = content {
has_react = c.contains("React") || c.contains("react") || c.contains("JSX") || c.contains("jsx") || c.contains("useState") || c.contains("Component");
}
}
if has_react || detected_patterns.iter().any(|p| {
let lp = p.to_lowercase();
lp.contains("react") || lp.contains("jsx") || lp.contains("hooks")
}) {
tags.insert("react".to_string());
}
let mut has_cjs = ext == "cjs";
if !has_cjs {
if let Some(c) = content {
has_cjs = c.contains("require(") || c.contains("module.exports") || c.contains("exports.");
}
}
if has_cjs || detected_patterns.iter().any(|p| {
let lp = p.to_lowercase();
lp.contains("require") || lp.contains("exports")
}) {
tags.insert("commonjs".to_string());
}
let mut has_esm = ext == "mjs";
if !has_esm {
if let Some(c) = content {
has_esm = (c.contains("import ") && c.contains(" from ")) || c.contains("export ") || c.contains("export default");
}
}
if has_esm || detected_patterns.iter().any(|p| {
let lp = p.to_lowercase();
lp.contains("import") || lp.contains("export")
}) {
tags.insert("esm".to_string());
}
tags.into_iter().collect()
}
pub fn compute_tags_for_file(
path: &Path,
content_opt: Option<&str>,
patterns: &[String],
is_risky: bool,
is_ignored: bool,
) -> Vec<String> {
if is_ignored {
return vec!["ignored".to_string()];
}
let content = content_opt.map(|s| s.to_string()).unwrap_or_else(|| {
std::fs::read_to_string(path).unwrap_or_default()
});
compute_file_tags(path, Some(&content), patterns, is_risky, is_ignored)
}
#[derive(Debug, Clone, Default)]
pub struct DetectionReport {
pub analyses: Vec<FileAnalysis>,
pub skipped_files: Vec<PathBuf>,
pub total_files: usize,
pub parseable_files: usize,
pub failed_files: Vec<DetectionFailure>,
}
impl DetectionReport {
pub fn safe_transforms(&self) -> usize {
self.analyses
.iter()
.filter(|analysis| analysis.classification == FileClassification::Safe)
.count()
}
pub fn risky_transforms(&self) -> usize {
self.analyses
.iter()
.filter(|analysis| analysis.classification == FileClassification::Risky)
.count()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransformMode {
DryRun,
Write,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransformConfidence {
Safe,
Moderate,
Risky,
}
impl std::fmt::Display for TransformConfidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Safe => write!(f, "safe"),
Self::Moderate => write!(f, "moderate"),
Self::Risky => write!(f, "risky"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TransformOptions {
pub mode: TransformMode,
pub review: bool,
pub autofix: bool,
pub format: bool,
pub prettier: bool,
pub no_format: bool,
}
#[derive(Debug, Clone)]
pub struct SkippedTransform {
pub path: PathBuf,
#[allow(dead_code)]
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct UnsupportedPatternReport {
pub path: PathBuf,
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct TransformReport {
pub changed_files: Vec<PathBuf>,
pub skipped_files: Vec<SkippedTransform>,
pub unsupported_patterns: Vec<UnsupportedPatternReport>,
pub file_confidences: std::collections::HashMap<PathBuf, TransformConfidence>,
}
impl TransformReport {
pub fn changed_file_count(&self) -> usize {
self.changed_files.len()
}
pub fn populate_confidences(&mut self, detection_report: &DetectionReport) {
for path in &self.changed_files {
let confidence = self.calculate_confidence(path, detection_report);
self.file_confidences.insert(path.clone(), confidence);
}
}
pub fn calculate_confidence(
&self,
path: &Path,
detection_report: &DetectionReport,
) -> TransformConfidence {
let mut score = 100;
if self.unsupported_patterns.iter().any(|p| &p.path == path) {
score -= 40;
}
if let Some(analysis) = detection_report.analyses.iter().find(|a| &a.path == path) {
if analysis.confidence_score < 50 {
score -= 30;
} else if analysis.confidence_score < 80 {
score -= 10;
}
if analysis.classification == FileClassification::Risky {
score -= 20;
}
}
if detection_report.failed_files.iter().any(|f| &f.path == path) {
score -= 50;
}
if score >= 80 {
TransformConfidence::Safe
} else if score >= 40 {
TransformConfidence::Moderate
} else {
TransformConfidence::Risky
}
}
}
pub trait Recipe: Send + Sync {
fn metadata(&self) -> &'static RecipeMetadata;
fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport>;
fn transform(
&self,
report: &DetectionReport,
options: TransformOptions,
) -> Result<TransformReport>;
}