use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::core::recipe::FileAnalysis;
use crate::recipes::js_to_ts::types::MigrationMode;
pub struct JsToTsTransform {
#[allow(dead_code)]
mode: MigrationMode,
rename_extensions: bool,
#[allow(dead_code)]
infer_types: bool,
}
impl JsToTsTransform {
pub fn new(mode: MigrationMode, rename_extensions: bool, infer_types: bool) -> Self {
Self {
mode,
rename_extensions,
infer_types,
}
}
pub fn transform_file(&self, analysis: &FileAnalysis) -> Result<TransformResult> {
let source_path = &analysis.path;
if self.rename_extensions {
let target_path = self.calculate_target_path(source_path)?;
let from_ext = source_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string();
let to_ext = target_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_string();
Ok(TransformResult {
original_path: source_path.clone(),
target_path,
changes: vec![Change::RenameExtension {
from: from_ext,
to: to_ext,
}],
warnings: Vec::new(),
})
} else {
Ok(TransformResult {
original_path: source_path.clone(),
target_path: source_path.clone(),
changes: Vec::new(),
warnings: Vec::new(),
})
}
}
fn calculate_target_path(&self, path: &Path) -> Result<PathBuf> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let new_ext = match ext {
"js" => "ts",
"jsx" => "tsx",
"mjs" => "mts",
_ => return Ok(path.to_path_buf()),
};
Ok(path.with_extension(new_ext))
}
pub fn should_transform(&self, analysis: &FileAnalysis) -> bool {
if !self.rename_extensions {
return false;
}
let ext = analysis
.path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
matches!(ext, "js" | "jsx" | "mjs")
}
}
#[derive(Debug, Clone)]
pub struct TransformResult {
pub original_path: PathBuf,
pub target_path: PathBuf,
pub changes: Vec<Change>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Change {
RenameExtension { from: String, to: String },
AddTypeAnnotations { count: usize },
ConvertCommonJsToEsm { count: usize },
RenameJsxToTsx,
}
impl TransformResult {
pub fn has_changes(&self) -> bool {
!self.changes.is_empty()
}
#[allow(dead_code)]
pub fn change_summary(&self) -> String {
if self.changes.is_empty() {
return "No changes".to_string();
}
let mut parts = Vec::new();
for change in &self.changes {
match change {
Change::RenameExtension { from, to } => {
parts.push(format!(".{} → .{}", from, to));
}
Change::AddTypeAnnotations { count } => {
parts.push(format!("Added {} type annotations", count));
}
Change::ConvertCommonJsToEsm { count } => {
parts.push(format!("Converted {} CommonJS to ESM", count));
}
Change::RenameJsxToTsx => {
parts.push("JSX → TSX".to_string());
}
}
}
parts.join(", ")
}
}
pub fn rename_file(from: &Path, to: &Path) -> Result<()> {
if from == to {
return Ok(());
}
if to.exists() {
anyhow::bail!("Target file already exists: {}", to.display());
}
fs::rename(from, to).map_err(|e| anyhow::anyhow!("Failed to rename file: {}", e))?;
Ok(())
}
#[allow(dead_code)]
pub fn get_target_extension(source_ext: &str) -> String {
match source_ext {
"js" => "ts".to_string(),
"jsx" => "tsx".to_string(),
"mjs" => "mts".to_string(),
_ => source_ext.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_analysis(ext: &str) -> FileAnalysis {
FileAnalysis {
path: PathBuf::from(format!("test.{}", ext)),
detected_patterns: vec!["plain JavaScript".to_string()],
confidence_score: 50,
classification: crate::core::recipe::FileClassification::Safe,
is_transform_safe: true,
tags: Default::default(),
}
}
#[test]
fn test_target_extension_calculation() {
assert_eq!(get_target_extension("js"), "ts");
assert_eq!(get_target_extension("jsx"), "tsx");
assert_eq!(get_target_extension("mjs"), "mts");
assert_eq!(get_target_extension("ts"), "ts");
}
#[test]
fn test_should_transform_js() {
let transform = JsToTsTransform::new(MigrationMode::Conservative, true, false);
let analysis = test_analysis("js");
assert!(transform.should_transform(&analysis));
}
#[test]
fn test_should_transform_ts() {
let transform = JsToTsTransform::new(MigrationMode::Conservative, true, false);
let analysis = test_analysis("ts");
assert!(!transform.should_transform(&analysis));
}
#[test]
fn test_transform_without_rename() {
let transform = JsToTsTransform::new(MigrationMode::Conservative, false, false);
let analysis = test_analysis("js");
let result = transform.transform_file(&analysis).unwrap();
assert!(!result.has_changes());
}
#[test]
fn test_transform_with_rename() {
let transform = JsToTsTransform::new(MigrationMode::Conservative, true, false);
let analysis = test_analysis("js");
let result = transform.transform_file(&analysis).unwrap();
assert!(result.has_changes());
assert_eq!(result.target_path.extension().unwrap(), "ts");
}
#[test]
fn test_transform_jsx_to_tsx() {
let transform = JsToTsTransform::new(MigrationMode::Conservative, true, false);
let analysis = test_analysis("jsx");
let result = transform.transform_file(&analysis).unwrap();
assert!(result.has_changes());
assert_eq!(result.target_path.extension().unwrap(), "tsx");
}
#[test]
fn test_change_summary() {
let result = TransformResult {
original_path: PathBuf::from("app.js"),
target_path: PathBuf::from("app.ts"),
changes: vec![Change::RenameExtension {
from: "js".to_string(),
to: "ts".to_string(),
}],
warnings: Vec::new(),
};
assert!(result.change_summary().contains(".js → .ts"));
}
}