morph-cli 0.1.0

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

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DryRunSnapshot {
    pub id: String,
    pub timestamp: u64,
    pub target_path: PathBuf,
    pub recipes: Vec<String>,
    pub changed_files_count: usize,
    pub risky_files_count: usize,
    pub warnings: Vec<String>,
}

const DRY_RUN_DIR: &str = ".morph-cli/dry-runs";

pub struct DryRunSnapshotStore {
    root: PathBuf,
}

impl DryRunSnapshotStore {
    pub fn new(project_root: &Path) -> Self {
        Self {
            root: project_root.join(DRY_RUN_DIR),
        }
    }

    pub fn save(&self, snapshot: &DryRunSnapshot) -> Result<()> {
        fs::create_dir_all(&self.root).with_context(|| {
            format!("Failed to create dry-runs snapshots directory: {}", self.root.display())
        })?;

        let path = self.root.join(format!("{}.json", snapshot.id));
        let json = serde_json::to_string_pretty(snapshot)
            .context("Failed to serialize dry-run snapshot")?;
        fs::write(&path, json)
            .with_context(|| format!("Failed to write dry-run snapshot: {}", path.display()))?;
        Ok(())
    }

    pub fn load(&self, id: &str) -> Result<Option<DryRunSnapshot>> {
        let path = self.root.join(format!("{}.json", id));
        if !path.exists() {
            return Ok(None);
        }

        let content = fs::read_to_string(&path)
            .with_context(|| format!("Failed to read dry-run snapshot: {}", path.display()))?;
        let snapshot = serde_json::from_str(&content)
            .with_context(|| format!("Failed to parse dry-run snapshot: {}", path.display()))?;
        Ok(Some(snapshot))
    }

    pub fn list(&self) -> Result<Vec<DryRunSnapshot>> {
        if !self.root.exists() {
            return Ok(Vec::new());
        }

        let mut snapshots = Vec::new();
        for entry in fs::read_dir(&self.root)? {
            let entry = entry?;
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) == Some("json") {
                if let Ok(content) = fs::read_to_string(&path) {
                    if let Ok(snap) = serde_json::from_str::<DryRunSnapshot>(&content) {
                        snapshots.push(snap);
                    }
                }
            }
        }

        snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
        Ok(snapshots)
    }
}