morph-cli 0.1.0

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

use anyhow::{Context, Result, bail};
use indicatif::ProgressBar;
use walkdir::WalkDir;

use crate::core::registry::RecipeRegistry;
use crate::utils::terminal;

const DEFAULT_DEBOUNCE_MS: u64 = 500;
const POLL_INTERVAL_MS: u64 = 250;

pub fn execute(path: &Path, recipes: &[String], debounce_ms: u64) -> Result<()> {
    if !path.exists() {
        bail!("Watch path does not exist: {}", path.display());
    }

    let path = if path.is_relative() {
        std::env::current_dir()?.join(path)
    } else {
        path.to_path_buf()
    };
    let debounce = Duration::from_millis(if debounce_ms == 0 {
        DEFAULT_DEBOUNCE_MS
    } else {
        debounce_ms
    });

    let registry = RecipeRegistry::new();
    let selected_recipes = selected_recipes(&registry, recipes)?;
    let mut snapshot = scan_snapshot(&path);
    let mut pending = HashSet::<PathBuf>::new();
    let mut last_change = None::<SystemTime>;

    println!("{}", terminal::label("Watch Mode"));
    println!("path: {}", path.display());
    println!(
        "recipes: {}",
        selected_recipes
            .iter()
            .map(|recipe| recipe.metadata().name)
            .collect::<Vec<_>>()
            .join(", ")
    );
    println!("debounce: {}ms", debounce.as_millis());

    loop {
        let current = scan_snapshot(&path);
        let changed = changed_files(&snapshot, &current);

        if !changed.is_empty() {
            pending.extend(changed);
            last_change = Some(SystemTime::now());
            snapshot = current;
        }

        if !pending.is_empty()
            && last_change
                .and_then(|last| last.elapsed().ok())
                .is_some_and(|elapsed| elapsed >= debounce)
        {
            let files = pending.drain().collect::<Vec<_>>();
            run_detection_cycle(&selected_recipes, &files)?;
            last_change = None;
        }

        thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
    }
}

fn selected_recipes<'a>(
    registry: &'a RecipeRegistry,
    recipes: &[String],
) -> Result<Vec<&'a dyn crate::core::recipe::Recipe>> {
    if recipes.is_empty() {
        return Ok(registry.all());
    }

    let mut selected = Vec::new();
    for name in recipes {
        selected.push(
            registry
                .find(name)
                .with_context(|| format!("Unknown recipe `{}`", name))?,
        );
    }
    Ok(selected)
}

fn run_detection_cycle(
    recipes: &[&dyn crate::core::recipe::Recipe],
    files: &[PathBuf],
) -> Result<()> {
    let progress = ProgressBar::hidden();
    let mut triggered = Vec::new();
    let mut skipped_files = 0usize;

    for recipe in recipes {
        let metadata = recipe.metadata();
        let matching_files = files
            .iter()
            .filter(|path| has_supported_extension(path, metadata.supported_extensions))
            .cloned()
            .collect::<Vec<_>>();

        if matching_files.is_empty() {
            continue;
        }

        triggered.push(metadata.name);
        for file in &matching_files {
            let report = recipe.detect(file, &progress)?;
            skipped_files += report.skipped_files.len() + report.failed_files.len();
            if report.analyses.is_empty() {
                skipped_files += 1;
            }
        }
    }

    println!();
    println!("{}", terminal::label("Watch Summary"));
    println!("changed files: {}", files.len());
    println!(
        "triggered recipes: {}",
        if triggered.is_empty() {
            "none".to_string()
        } else {
            triggered.join(", ")
        }
    );
    println!("skipped files: {}", skipped_files);

    Ok(())
}

fn scan_snapshot(root: &Path) -> HashMap<PathBuf, SystemTime> {
    WalkDir::new(root)
        .into_iter()
        .filter_entry(|e| {
            let name = e.file_name().to_string_lossy();
            name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
        })
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_file())
        .filter(|entry| !is_ignored_path(entry.path()))
        .filter_map(|entry| {
            let modified = entry.metadata().ok()?.modified().ok()?;
            Some((entry.path().to_path_buf(), modified))
        })
        .collect()
}

fn changed_files(
    previous: &HashMap<PathBuf, SystemTime>,
    current: &HashMap<PathBuf, SystemTime>,
) -> Vec<PathBuf> {
    current
        .iter()
        .filter_map(|(path, modified)| match previous.get(path) {
            Some(previous_modified) if previous_modified == modified => None,
            _ => Some(path.clone()),
        })
        .collect()
}

fn has_supported_extension(path: &Path, supported_extensions: &[&str]) -> bool {
    path.extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| supported_extensions.contains(&extension))
}

fn is_ignored_path(path: &Path) -> bool {
    path.components().any(|component| {
        let value = component.as_os_str().to_string_lossy();
        matches!(
            value.as_ref(),
            ".git" | ".morph-cli" | "node_modules" | "target" | "dist" | "build"
        )
    })
}