morph-cli 0.1.0

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

pub struct SnapshotManager {
    pub snapshot_dir: PathBuf,
    pub update_mode: bool,
    snapshots: HashMap<String, Snapshot>,
}

#[derive(Debug, Clone)]
pub struct Snapshot {
    pub name: String,
    pub content: String,
    pub metadata: SnapshotMetadata,
}

#[derive(Debug, Clone, Default)]
pub struct SnapshotMetadata {
    pub created: String,
    pub recipe: Option<String>,
    pub language: Option<String>,
}

impl SnapshotManager {
    pub fn new(snapshot_dir: PathBuf, update_mode: bool) -> Self {
        Self {
            snapshot_dir,
            update_mode,
            snapshots: HashMap::new(),
        }
    }

    pub fn load(&mut self) -> std::io::Result<()> {
        if !self.snapshot_dir.exists() {
            fs::create_dir_all(&self.snapshot_dir)?;
            return Ok(());
        }

        for entry in walkdir::WalkDir::new(&self.snapshot_dir)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            if entry.file_type().is_file()
                && entry
                    .path()
                    .extension()
                    .map(|e| e == "snap")
                    .unwrap_or(false)
            {
                if let Ok(content) = fs::read_to_string(entry.path()) {
                    let name = entry
                        .path()
                        .file_stem()
                        .and_then(|s| s.to_str())
                        .unwrap_or("unknown")
                        .to_string();

                    let metadata = Self::parse_metadata(&content);
                    let snapshot = Snapshot {
                        name: name.clone(),
                        content: Self::strip_metadata(&content),
                        metadata,
                    };

                    self.snapshots.insert(name, snapshot);
                }
            }
        }

        Ok(())
    }

    pub fn save(
        &mut self,
        name: &str,
        content: &str,
        metadata: SnapshotMetadata,
    ) -> std::io::Result<()> {
        if !self.snapshot_dir.exists() {
            fs::create_dir_all(&self.snapshot_dir)?;
        }

        let full_content = format!(
            "// @snapshot {}\n// Created: {}\n{}\n",
            name, metadata.created, content
        );

        let path = self.snapshot_dir.join(format!("{}.snap", name));
        fs::write(path, full_content)?;

        self.snapshots.insert(
            name.to_string(),
            Snapshot {
                name: name.to_string(),
                content: content.to_string(),
                metadata,
            },
        );

        Ok(())
    }

    pub fn get(&self, name: &str) -> Option<&Snapshot> {
        self.snapshots.get(name)
    }

    pub fn update(&mut self, name: &str, content: &str) -> std::io::Result<()> {
        let metadata = SnapshotMetadata {
            created: chrono::Utc::now().to_rfc3339(),
            recipe: Some(name.to_string()),
            language: None,
        };
        self.save(name, content, metadata)
    }

    pub fn list(&self) -> Vec<&Snapshot> {
        self.snapshots.values().collect()
    }

    pub fn exists(&self, name: &str) -> bool {
        self.snapshots.contains_key(name)
    }

    fn parse_metadata(content: &str) -> SnapshotMetadata {
        let mut metadata = SnapshotMetadata::default();

        for line in content.lines() {
            if line.starts_with("// @snapshot ") {
                metadata.recipe = Some(line.replace("// @snapshot ", "").trim().to_string());
            }
            if line.starts_with("// Created: ") {
                metadata.created = line.replace("// Created: ", "").trim().to_string();
            }
        }

        metadata
    }

    fn strip_metadata(content: &str) -> String {
        content
            .lines()
            .filter(|l| !l.starts_with("// @snapshot") && !l.starts_with("// Created:"))
            .collect::<Vec<&str>>()
            .join("\n")
    }
}

pub fn generate_snapshot_name(recipe: &str, file: &Path) -> String {
    let file_name = file
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("unknown");
    format!("{}_{}", recipe, file_name)
}

pub fn format_snapshot_diff(expected: &str, actual: &str) -> String {
    let mut diff = String::from("Snapshot mismatch:\n\n");

    let exp_lines: Vec<&str> = expected.lines().collect();
    let act_lines: Vec<&str> = actual.lines().collect();

    let max = exp_lines.len().max(act_lines.len());

    for i in 0..max {
        let exp = exp_lines.get(i).unwrap_or(&"<missing>");
        let act = act_lines.get(i).unwrap_or(&"<missing>");

        if exp != act {
            diff.push_str(&format!("- {}\n", exp));
            diff.push_str(&format!("+ {}\n", act));
        }
    }

    diff
}

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

    #[test]
    fn test_snapshot_manager_new() {
        let dir = tempfile::tempdir().unwrap();
        let manager = SnapshotManager::new(dir.path().to_path_buf(), false);
        assert!(manager.snapshots.is_empty());
    }

    #[test]
    fn test_snapshot_manager_empty_exists() {
        let dir = tempfile::tempdir().unwrap();
        let manager = SnapshotManager::new(dir.path().to_path_buf(), false);
        assert!(!manager.exists("nonexistent"));
    }

    #[test]
    fn test_generate_snapshot_name() {
        let path = Path::new("test.js");
        let name = generate_snapshot_name("express-to-fastify", path);
        assert!(name.contains("express-to-fastify"));
        assert!(name.contains("test.js"));
    }

    #[test]
    fn test_format_snapshot_diff() {
        let diff = format_snapshot_diff("line1\nline2", "line1\nline3");
        assert!(diff.contains("- line2"));
        assert!(diff.contains("+ line3"));
    }

    #[test]
    fn test_strip_metadata() {
        let content = "// @snapshot test\n// Created: today\nactual content";
        let stripped = SnapshotManager::new(std::env::temp_dir().as_path().to_path_buf(), false)
            .load()
            .ok();
        assert!(stripped.is_some() || content.contains("actual content"));
    }
}