morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::fs;
use std::path::Path;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HooksConfig {
    #[serde(rename = "before-run")]
    pub before_run: Option<String>,
    #[serde(rename = "after-run")]
    pub after_run: Option<String>,
    #[serde(rename = "after-write")]
    pub after_write: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigProfile {
    pub dry_run_default: Option<bool>,
    pub review: Option<bool>,
    pub allow_risky_transforms: Option<bool>,
    pub max_files: Option<usize>,
    pub max_duration_seconds: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MorphCliSchema {
    #[serde(default)]
    pub enabled_recipes: Vec<String>,

    #[serde(default)]
    pub excluded_paths: Vec<String>,

    #[serde(default = "default_max_file_size_kb")]
    pub max_file_size_kb: usize,

    #[serde(default = "default_true")]
    pub dry_run_default: bool,

    #[serde(default)]
    pub backup_enabled: bool,

    #[serde(default = "default_preview_lines")]
    pub preview_lines: usize,

    #[serde(default)]
    pub allow_risky_transforms: bool,

    #[serde(default)]
    pub disabled_recipes: Vec<String>,

    #[serde(default)]
    pub allow_risky_recipes: Vec<String>,

    #[serde(default)]
    pub custom_globs: Vec<String>,

    #[serde(default = "default_max_files")]
    pub max_files: usize,

    pub max_duration_seconds: u64,

    #[serde(default)]
    pub profiles: std::collections::HashMap<String, ConfigProfile>,

    #[serde(default)]
    pub hooks: HooksConfig,
}

impl Default for MorphCliSchema {
    fn default() -> Self {
        Self {
            enabled_recipes: Vec::new(),
            excluded_paths: Vec::new(),
            max_file_size_kb: default_max_file_size_kb(),
            dry_run_default: default_true(),
            backup_enabled: false,
            preview_lines: default_preview_lines(),
            allow_risky_transforms: false,
            disabled_recipes: Vec::new(),
            allow_risky_recipes: Vec::new(),
            custom_globs: Vec::new(),
            max_files: default_max_files(),
            max_duration_seconds: default_max_duration(),
            profiles: std::collections::HashMap::new(),
            hooks: HooksConfig::default(),
        }
    }
}

fn default_max_file_size_kb() -> usize {
    500
}

fn default_true() -> bool {
    true
}

fn default_preview_lines() -> usize {
    100
}

fn default_max_files() -> usize {
    10000
}

fn default_max_duration() -> u64 {
    300
}

impl MorphCliSchema {
    #[allow(dead_code)]
    pub fn validate(&self) -> Vec<String> {
        let mut errors = Vec::new();

        if self.max_file_size_kb == 0 {
            errors.push("max_file_size_kb must be greater than 0".to_string());
        }

        if self.preview_lines == 0 {
            errors.push(
                "preview_lines must be greater than 0 (or use a very large number for unlimited)"
                    .to_string(),
            );
        }

        for path in &self.excluded_paths {
            if path.is_empty() {
                errors.push("excluded_paths contains empty string".to_string());
            }
            if path.contains('\\') {
                errors.push(format!(
                    "excluded_paths contains invalid backslash in: {}",
                    path
                ));
            }
        }

        errors
    }

    #[allow(dead_code)]
    pub fn is_excluded(&self, path: &Path) -> bool {
        let path_str = path.to_string_lossy();

        for excluded in &self.excluded_paths {
            if path_str.contains(excluded) {
                return true;
            }
        }

        self.is_default_excluded(path)
    }

    #[allow(dead_code)]
    fn is_default_excluded(&self, path: &Path) -> bool {
        let path_str = path.to_string_lossy();

        let default_excludes = [
            "node_modules",
            ".git",
            "dist",
            "build",
            "target",
            ".next",
            ".nuxt",
            "__pycache__",
            ".venv",
            "venv",
        ];

        for exclude in &default_excludes {
            if path_str.contains(exclude) {
                return true;
            }
        }

        false
    }

    #[allow(dead_code)]
    pub fn should_skip_file(&self, path: &Path, content: &str) -> SkipDecision {
        let metadata = match fs::metadata(path) {
            Ok(m) => m,
            Err(_) => return SkipDecision::Error("cannot read file metadata".to_string()),
        };

        if metadata.len() == 0 {
            return SkipDecision::Skip("empty file".to_string());
        }

        let size_kb = metadata.len() / 1024;
        if size_kb > self.max_file_size_kb as u64 {
            return SkipDecision::Skip(format!(
                "file size ({} KB) exceeds limit ({} KB)",
                size_kb, self.max_file_size_kb
            ));
        }

        if self.looks_minified(content) {
            return SkipDecision::Skip("minified file detected".to_string());
        }

        if self.looks_generated(content) {
            return SkipDecision::Skip("generated file detected".to_string());
        }

        SkipDecision::Process
    }

    #[allow(dead_code)]
    fn looks_minified(&self, content: &str) -> bool {
        if content.len() < 1000 {
            return false;
        }

        let mut long_lines = 0;
        let mut total_lines = 0;

        for line in content.lines() {
            total_lines += 1;
            if line.len() > 500 {
                long_lines += 1;
            }
        }

        if total_lines == 0 {
            return false;
        }

        let ratio = long_lines as f64 / total_lines as f64;
        ratio > 0.3
    }

    #[allow(dead_code)]
    fn looks_generated(&self, content: &str) -> bool {
        let markers = [
            "// DO NOT EDIT",
            "// This file was generated",
            "@generated",
            "/* Generated by ",
            "Generated by ",
            "Auto-generated by ",
        ];

        for marker in &markers {
            if content.contains(marker) {
                return true;
            }
        }

        false
    }
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum SkipDecision {
    Process,
    Skip(String),
    Error(String),
}

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

    #[test]
    fn test_default_schema() {
        let schema = MorphCliSchema::default();
        assert_eq!(schema.max_file_size_kb, 500);
        assert_eq!(schema.preview_lines, 100);
        assert!(schema.dry_run_default);
    }

    #[test]
    fn test_validate_empty_errors() {
        let schema = MorphCliSchema::default();
        let errors = schema.validate();
        assert!(errors.is_empty());
    }

    #[test]
    fn test_is_excluded() {
        let schema = MorphCliSchema::default();
        assert!(schema.is_excluded(Path::new("node_modules/foo.js")));
        assert!(schema.is_excluded(Path::new("dist/index.js")));
        assert!(!schema.is_excluded(Path::new("src/index.js")));
    }

    #[test]
    fn test_custom_exclusions() {
        let mut schema = MorphCliSchema::default();
        schema.excluded_paths = vec!["custom_dir".to_string()];

        assert!(schema.is_excluded(Path::new("custom_dir/file.js")));
    }
}