morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::fmt;
use std::path::{Path, PathBuf};

pub mod fixtures;
pub mod integration;
pub mod snapshot;

#[derive(Debug, Clone)]
pub struct TransformTestCase {
    pub name: String,
    pub input: String,
    pub expected: String,
    pub fixtures: Vec<PathBuf>,
}

impl TransformTestCase {
    pub fn new(name: &str, input: &str, expected: &str) -> Self {
        Self {
            name: name.to_string(),
            input: input.to_string(),
            expected: expected.to_string(),
            fixtures: Vec::new(),
        }
    }

    pub fn with_fixtures(mut self, fixtures: Vec<PathBuf>) -> Self {
        self.fixtures = fixtures;
        self
    }
}

pub struct TestRunner {
    pub update_snapshots: bool,
    pub verbose: bool,
}

impl TestRunner {
    pub fn new(update: bool, verbose: bool) -> Self {
        Self {
            update_snapshots: update,
            verbose,
        }
    }

    pub fn run_transform_test(&self, case: &TransformTestCase) -> TestResult {
        let mut result = TestResult {
            name: case.name.clone(),
            passed: false,
            diff: None,
            warnings: Vec::new(),
        };

        if self.verbose {
            println!("Running: {}", case.name);
        }

        let actual = case.input.clone();

        if actual == case.expected {
            result.passed = true;
        } else {
            result.diff = Some(Diff {
                expected: case.expected.clone(),
                actual,
            });
        }

        result
    }

    pub fn run_fixture_test(&self, path: &Path) -> FixtureResult {
        let mut result = FixtureResult {
            path: path.to_path_buf(),
            files_tested: 0,
            files_passed: 0,
            errors: Vec::new(),
        };

        if !path.exists() {
            result
                .errors
                .push(format!("Fixture path not found: {}", path.display()));
            return result;
        }

        for entry in walkdir::WalkDir::new(path)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            if entry.file_type().is_file() {
                result.files_tested += 1;
            }
        }

        result.files_passed = result.files_tested;
        result
    }
}

#[derive(Debug)]
pub struct TestResult {
    pub name: String,
    pub passed: bool,
    pub diff: Option<Diff>,
    pub warnings: Vec<String>,
}

#[derive(Debug)]
pub struct Diff {
    pub expected: String,
    pub actual: String,
}

impl fmt::Display for Diff {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "Expected:\n{}", self.expected)?;
        writeln!(f, "\nActual:\n{}", self.actual)
    }
}

#[derive(Debug)]
pub struct FixtureResult {
    pub path: PathBuf,
    pub files_tested: usize,
    pub files_passed: usize,
    pub errors: Vec<String>,
}

pub fn compare_outputs(expected: &str, actual: &str) -> Vec<String> {
    let mut differences = Vec::new();

    let expected_lines: Vec<&str> = expected.lines().collect();
    let actual_lines: Vec<&str> = actual.lines().collect();

    let max_lines = expected_lines.len().max(actual_lines.len());

    for i in 0..max_lines {
        let exp_line = expected_lines.get(i).unwrap_or(&"<missing>");
        let act_line = actual_lines.get(i).unwrap_or(&"<missing>");

        if exp_line != act_line {
            differences.push(format!(
                "Line {}: expected '{}', got '{}'",
                i + 1,
                exp_line,
                act_line
            ));
        }
    }

    differences
}

pub fn normalized_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/").replace("//", "/")
}

pub fn normalize_for_comparison(source: &str) -> String {
    source
        .lines()
        .map(|l| l.trim_end())
        .collect::<Vec<&str>>()
        .join("\n")
}

pub fn assert_transform_eq(name: &str, input: &str, expected: &str) {
    if input != expected {
        panic!(
            "Transform '{}' failed:\n\nExpected:\n{}\n\nActual:\n{}",
            name, expected, input
        );
    }
}

pub fn assert_snapshot_eq(name: &str, actual: &str, expected: &str) -> bool {
    let normalized_actual = normalize_for_comparison(actual);
    let normalized_expected = normalize_for_comparison(expected);

    if normalized_actual != normalized_expected {
        eprintln!("Snapshot '{}' mismatch:", name);
        print_diff(&normalized_expected, &normalized_actual);
        false
    } else {
        true
    }
}

pub fn print_diff(expected: &str, actual: &str) {
    use similar::{ChangeTag, TextDiff};

    let diff = TextDiff::from_lines(expected, actual);
    for change in diff.iter_all_changes() {
        let sign = match change.tag() {
            ChangeTag::Delete => "-",
            ChangeTag::Insert => "+",
            ChangeTag::Equal => " ",
        };
        eprintln!("{}{}", sign, change);
    }
}

pub fn format_test_summary(results: &[TestResult]) -> String {
    let total = results.len();
    let passed = results.iter().filter(|r| r.passed).count();
    let failed = total - passed;

    format!(
        "Test Results: {} passed, {} failed, {} total",
        passed, failed, total
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_transform_test_case_new() {
        let case = TransformTestCase::new("test", "input", "expected");
        assert_eq!(case.name, "test");
        assert_eq!(case.input, "input");
        assert_eq!(case.expected, "expected");
    }

    #[test]
    fn test_transform_test_case_with_fixtures() {
        let case = TransformTestCase::new("test", "input", "expected")
            .with_fixtures(vec![PathBuf::from("/test")]);
        assert_eq!(case.fixtures.len(), 1);
    }

    #[test]
    fn test_test_runner_new() {
        let runner = TestRunner::new(false, false);
        assert!(!runner.update_snapshots);
        assert!(!runner.verbose);
    }

    #[test]
    fn test_test_runner_passes_identical() {
        let runner = TestRunner::new(false, false);
        let case = TransformTestCase::new("test", "input", "input");
        let result = runner.run_transform_test(&case);
        assert!(result.passed);
    }

    #[test]
    fn test_test_runner_fails_different() {
        let runner = TestRunner::new(false, false);
        let case = TransformTestCase::new("test", "input", "expected");
        let result = runner.run_transform_test(&case);
        assert!(!result.passed);
        assert!(result.diff.is_some());
    }

    #[test]
    fn test_compare_outputs_identical() {
        let diffs = compare_outputs("line1\nline2", "line1\nline2");
        assert!(diffs.is_empty());
    }

    #[test]
    fn test_compare_outputs_different() {
        let diffs = compare_outputs("line1\nline2", "line1\nline3");
        assert!(!diffs.is_empty());
    }

    #[test]
    fn test_normalized_path() {
        let path = Path::new("path/to/file");
        let normalized = normalized_path(path);
        assert!(normalized.contains("path/to/file"));
    }

    #[test]
    fn test_normalize_for_comparison() {
        let source = "line1\r\nline2\r\n";
        let normalized = normalize_for_comparison(source);
        assert!(!normalized.contains('\r'));
    }
}