morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Serialize, Deserialize};
use anyhow::{Context, Result};

use crate::core::detection::scanner::Scanner;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileStatistics {
    pub total_files: usize,
    pub scanned_files: usize,
    pub skipped_files: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectFingerprint {
    pub timestamp: u64,
    pub detected_frameworks: Vec<String>,
    pub dependencies: BTreeMap<String, String>,
    pub workspaces: Vec<String>,
    pub file_statistics: FileStatistics,
}

const FINGERPRINT_PATH: &str = ".morph-cli/project.json";

impl ProjectFingerprint {
    pub fn generate(project_root: &Path) -> Result<Self> {
        let mut scanner = Scanner::new(project_root.to_path_buf());
        let result = scanner.scan();

        let detected_frameworks = result.detection.frameworks.iter()
            .map(|f| {
                if let Some(v) = &f.version {
                    format!("{} ({})", f.name, v)
                } else {
                    f.name.clone()
                }
            })
            .collect();

        // Dependencies
        let mut dependencies = BTreeMap::new();
        let pkg_path = project_root.join("package.json");
        if let Some(pkg) = crate::core::detection::package_json::PackageJson::load(&pkg_path) {
            for (k, v) in pkg.dependencies {
                dependencies.insert(k, v);
            }
            for (k, v) in pkg.dev_dependencies {
                dependencies.insert(k, v);
            }
        }

        // Workspaces
        let workspaces = result.workspace.packages.iter()
            .map(|p| p.name.clone())
            .collect();

        let file_statistics = FileStatistics {
            total_files: result.total_files,
            scanned_files: result.scanned_files.len(),
            skipped_files: result.skipped_files.len(),
        };

        let timestamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        Ok(Self {
            timestamp,
            detected_frameworks,
            dependencies,
            workspaces,
            file_statistics,
        })
    }

    pub fn save(&self, project_root: &Path) -> Result<()> {
        let path = project_root.join(FINGERPRINT_PATH);
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)
            .context("Failed to serialize project fingerprint")?;
        std::fs::write(&path, json)
            .with_context(|| format!("Failed to write project fingerprint: {}", path.display()))?;
        Ok(())
    }

    pub fn load(project_root: &Path) -> Result<Option<Self>> {
        let path = project_root.join(FINGERPRINT_PATH);
        if !path.exists() {
            return Ok(None);
        }
        let content = std::fs::read_to_string(&path)
            .with_context(|| format!("Failed to read project fingerprint: {}", path.display()))?;
        let fingerprint = serde_json::from_str(&content)
            .with_context(|| format!("Failed to parse project fingerprint: {}", path.display()))?;
        Ok(Some(fingerprint))
    }

    pub fn compatibility_check(&self, current: &Self) -> Result<(), String> {
        // 1. Check frameworks
        let self_fws: std::collections::BTreeSet<_> = self.detected_frameworks.iter().collect();
        let current_fws: std::collections::BTreeSet<_> = current.detected_frameworks.iter().collect();
        if self_fws != current_fws {
            return Err(format!(
                "Detected frameworks changed: current={:?}, recorded={:?}",
                current.detected_frameworks, self.detected_frameworks
            ));
        }

        // 2. Check workspaces
        let self_ws: std::collections::BTreeSet<_> = self.workspaces.iter().collect();
        let current_ws: std::collections::BTreeSet<_> = current.workspaces.iter().collect();
        if self_ws != current_ws {
            return Err(format!(
                "Workspace packages structure changed: current={:?}, recorded={:?}",
                current.workspaces, self.workspaces
            ));
        }

        // 3. Check file count change (more than 50% change or absolute diff > 100)
        let diff = (self.file_statistics.total_files as isize - current.file_statistics.total_files as isize).abs();
        if self.file_statistics.total_files > 0 {
            let percentage = (diff as f64 / self.file_statistics.total_files as f64) * 100.0;
            if percentage > 50.0 && diff > 20 {
                return Err(format!(
                    "File counts changed significantly: current={}, recorded={} ({:.1}% change)",
                    current.file_statistics.total_files, self.file_statistics.total_files, percentage
                ));
            }
        }

        Ok(())
    }
}