morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransformReport {
    pub id: String,
    pub timestamp: i64,
    pub execution: ExecutionMetadata,
    pub recipes: Vec<RecipeSummary>,
    pub files: FileSummary,
    pub safety: SafetySummary,
    pub timing: TimingSummary,
    pub failures: Vec<FailureEntry>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rollback_session: Option<RollbackInfo>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionMetadata {
    pub command: String,
    pub version: String,
    pub mode: String,
    pub project_root: PathBuf,
    pub target_path: PathBuf,
    pub allow_risky: bool,
    pub strict: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecipeSummary {
    pub name: String,
    pub description: String,
    pub extensions: Vec<String>,
    pub files_processed: usize,
    pub files_changed: usize,
    pub files_skipped: usize,
    pub unsupported_patterns: usize,
    #[serde(default)]
    pub parse_failures: usize,
    #[serde(default)]
    pub execution_duration_ms: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSummary {
    pub total: usize,
    pub parseable: usize,
    pub analyses: Vec<FileAnalysis>,
    pub skipped: Vec<SkippedFile>,
    pub changed: Vec<ChangedFile>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileAnalysis {
    pub path: PathBuf,
    pub patterns: Vec<String>,
    pub confidence: u8,
    pub safety: String,
    pub is_safe: bool,
    #[serde(default)]
    pub tags: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkippedFile {
    pub path: PathBuf,
    pub reason: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangedFile {
    pub path: PathBuf,
    pub lines_added: usize,
    pub lines_removed: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub preview: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafetySummary {
    pub safe_count: usize,
    pub risky_count: usize,
    pub warnings: Vec<String>,
    pub recommendations: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimingSummary {
    pub total_ms: u64,
    pub per_recipe: HashMap<String, RecipeTiming>,
    pub per_file: HashMap<PathBuf, u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecipeTiming {
    pub detect_ms: u64,
    pub transform_ms: u64,
    pub total_ms: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureEntry {
    pub recipe: String,
    pub stage: String,
    pub error: String,
    pub affected_files: Vec<PathBuf>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RollbackInfo {
    pub session_id: String,
    pub created_at: i64,
    pub file_count: usize,
}

impl TransformReport {
    pub fn new(id: String, execution: ExecutionMetadata) -> Self {
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64;
        Self {
            id,
            timestamp,
            execution,
            recipes: Vec::new(),
            files: FileSummary {
                total: 0,
                parseable: 0,
                analyses: Vec::new(),
                skipped: Vec::new(),
                changed: Vec::new(),
            },
            safety: SafetySummary {
                safe_count: 0,
                risky_count: 0,
                warnings: Vec::new(),
                recommendations: Vec::new(),
            },
            timing: TimingSummary {
                total_ms: 0,
                per_recipe: HashMap::new(),
                per_file: HashMap::new(),
            },
            failures: Vec::new(),
            rollback_session: None,
        }
    }

    #[allow(dead_code)]
    pub fn total_changes(&self) -> usize {
        self.files.changed.len()
    }

    #[allow(dead_code)]
    pub fn total_skipped(&self) -> usize {
        self.files.skipped.len()
    }

    #[allow(dead_code)]
    pub fn has_failures(&self) -> bool {
        !self.failures.is_empty()
    }
}

impl Default for TransformReport {
    fn default() -> Self {
        Self::new(
            generate_report_id(),
            ExecutionMetadata {
                command: String::new(),
                version: env!("CARGO_PKG_VERSION").to_string(),
                mode: String::new(),
                project_root: PathBuf::new(),
                target_path: PathBuf::new(),
                allow_risky: false,
                strict: false,
            },
        )
    }
}

fn generate_report_id() -> String {
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    format!("morph-report-{}", timestamp)
}

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

    #[test]
    fn test_report_creation() {
        let report = TransformReport::default();
        assert!(!report.id.is_empty());
    }

    #[test]
    fn test_report_totals() {
        let mut report = TransformReport::default();
        report.files.changed.push(ChangedFile {
            path: PathBuf::from("test.js"),
            lines_added: 10,
            lines_removed: 5,
            preview: None,
        });
        assert_eq!(report.total_changes(), 1);
        assert_eq!(report.total_skipped(), 0);
        assert!(!report.has_failures());
    }

    #[test]
    fn test_serialization() {
        let report = TransformReport::default();
        let json = serde_json::to_string(&report).unwrap();
        assert!(json.contains("morph-report-"));
        let deserialized: TransformReport = serde_json::from_str(&json).unwrap();
        assert_eq!(report.id, deserialized.id);
    }
}