morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::core::recipe::{DetectionFailure, FileAnalysis};

const CACHE_DIR: &str = ".morph-cli/cache";

static CACHE_HITS: AtomicUsize = AtomicUsize::new(0);
static CACHE_MISSES: AtomicUsize = AtomicUsize::new(0);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedFileDetection {
    pub hash: String,
    pub metadata: CachedFileMetadata,
    pub outcome: CachedDetectionOutcome,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedFileMetadata {
    pub path: PathBuf,
    pub size: u64,
    pub modified_secs: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CachedDetectionOutcome {
    Analysis(FileAnalysis),
    Skipped(PathBuf),
    Failure(DetectionFailure),
}

#[derive(Debug, Clone, Copy)]
pub struct CacheStats {
    pub hits: usize,
    pub misses: usize,
}

pub fn load_detection(recipe_name: &str, path: &Path) -> Option<CachedDetectionOutcome> {
    let cache_path = cache_path(recipe_name, path);
    if !cache_path.exists() {
        return None;
    }

    let content = fs::read_to_string(&cache_path).ok()?;
    let cached = serde_json::from_str::<CachedFileDetection>(&content).ok()?;

    if let Ok(meta) = fs::metadata(path) {
        let modified_secs = meta
            .modified()
            .ok()
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_secs())
            .unwrap_or_default();

        if meta.len() == cached.metadata.size && modified_secs == cached.metadata.modified_secs {
            CACHE_HITS.fetch_add(1, Ordering::Relaxed);
            return Some(cached.outcome);
        }
    }

    let hash = file_hash(path).ok()?;
    if cached.hash == hash {
        CACHE_HITS.fetch_add(1, Ordering::Relaxed);
        Some(cached.outcome)
    } else {
        CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
        None
    }
}

pub fn save_detection(
    recipe_name: &str,
    path: &Path,
    outcome: &CachedDetectionOutcome,
) -> Result<()> {
    let cache_path = cache_path(recipe_name, path);
    if let Some(parent) = cache_path.parent() {
        fs::create_dir_all(parent)?;
    }

    let cached = CachedFileDetection {
        hash: file_hash(path)?,
        metadata: file_metadata(path)?,
        outcome: outcome.clone(),
    };
    let json = serde_json::to_string_pretty(&cached).context("failed to serialize cache entry")?;
    fs::write(cache_path, json).context("failed to write cache entry")?;
    Ok(())
}

pub fn record_miss() {
    CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
}

pub fn stats() -> CacheStats {
    CacheStats {
        hits: CACHE_HITS.load(Ordering::Relaxed),
        misses: CACHE_MISSES.load(Ordering::Relaxed),
    }
}

pub fn print_stats() {
    let stats = stats();
    println!("cache hits: {}", stats.hits);
    println!("cache misses: {}", stats.misses);
}

fn cache_path(recipe_name: &str, path: &Path) -> PathBuf {
    PathBuf::from(CACHE_DIR)
        .join(sanitize(recipe_name))
        .join(format!("{}.json", sanitize(&path.to_string_lossy())))
}

fn sanitize(value: &str) -> String {
    value
        .chars()
        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
        .collect()
}

fn file_hash(path: &Path) -> Result<String> {
    let content = fs::read(path)?;
    let mut hasher = DefaultHasher::new();
    content.hash(&mut hasher);
    Ok(format!("{:016x}", hasher.finish()))
}

fn file_metadata(path: &Path) -> Result<CachedFileMetadata> {
    let metadata = fs::metadata(path)?;
    let modified_secs = metadata
        .modified()
        .ok()
        .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|duration| duration.as_secs())
        .unwrap_or_default();

    Ok(CachedFileMetadata {
        path: path.to_path_buf(),
        size: metadata.len(),
        modified_secs,
    })
}