morph-cli 0.1.0

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

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

const SESSION_DIR: &str = ".morph-cli/sessions";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationSession {
    pub id: String,
    pub recipe_names: Vec<String>,
    pub started_at: u64,
    pub completed_at: u64,
    pub mode: String,
    pub target_path: PathBuf,
    pub modified_files: Vec<PathBuf>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub backup_session_id: Option<String>,
    #[serde(default)]
    pub options: SessionOptions,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionOptions {
    pub write: bool,
    pub review: bool,
    pub autofix: bool,
    pub allow_risky: bool,
    pub strict: bool,
    pub format: bool,
    pub prettier: bool,
    pub no_format: bool,
    pub jobs: Option<usize>,
    pub sequential: bool,
}

impl MigrationSession {
    pub fn new(recipe_names: Vec<String>, mode: impl Into<String>, target_path: PathBuf, options: SessionOptions) -> Self {
        let started_at = current_timestamp();
        Self {
            id: generate_session_id(),
            recipe_names,
            started_at,
            completed_at: started_at,
            mode: mode.into(),
            target_path,
            modified_files: Vec::new(),
            backup_session_id: None,
            options,
        }
    }

    pub fn complete(
        mut self,
        modified_files: Vec<PathBuf>,
        backup_session_id: Option<String>,
    ) -> Self {
        self.completed_at = current_timestamp();
        self.modified_files = modified_files;
        self.backup_session_id = backup_session_id;
        self
    }
}

pub struct SessionStore {
    root: PathBuf,
}

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

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

        let path = self.session_path(&session.id);
        let json =
            serde_json::to_string_pretty(session).context("Failed to serialize session metadata")?;
        fs::write(&path, json)
            .with_context(|| format!("Failed to write session metadata: {}", path.display()))?;
        Ok(())
    }

    pub fn load(&self, id: &str) -> Result<Option<MigrationSession>> {
        let path = self.session_path(id);
        if !path.exists() {
            return Ok(None);
        }

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

    pub fn list(&self) -> Result<Vec<MigrationSession>> {
        let mut sessions = Vec::new();

        if !self.root.exists() {
            return Ok(sessions);
        }

        for entry in fs::read_dir(&self.root)? {
            let entry = entry?;
            let path = entry.path();

            if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
                continue;
            }

            if let Ok(content) = fs::read_to_string(&path)
                && let Ok(session) = serde_json::from_str::<MigrationSession>(&content)
            {
                sessions.push(session);
            }
        }

        sessions.sort_by(|left, right| right.started_at.cmp(&left.started_at));
        Ok(sessions)
    }

    fn session_path(&self, id: &str) -> PathBuf {
        self.root.join(format!("{id}.json"))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationCheckpoint {
    pub id: String,
    pub session_id: String,
    pub completed_recipes: Vec<String>,
    pub remaining_recipes: Vec<String>,
    pub modified_files: Vec<PathBuf>,
    pub options: SessionOptions,
    pub target_path: PathBuf,
    pub timestamp: u64,
}

const CHECKPOINT_DIR: &str = ".morph-cli/checkpoints";

pub struct CheckpointStore {
    root: PathBuf,
}

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

    pub fn save(&self, checkpoint: &MigrationCheckpoint) -> Result<()> {
        fs::create_dir_all(&self.root).with_context(|| {
            format!(
                "Failed to create checkpoints directory: {}",
                self.root.display()
            )
        })?;

        let path = self.checkpoint_path(&checkpoint.id);
        let json =
            serde_json::to_string_pretty(checkpoint).context("Failed to serialize checkpoint metadata")?;
        fs::write(&path, json)
            .with_context(|| format!("Failed to write checkpoint metadata: {}", path.display()))?;
        Ok(())
    }

    pub fn load(&self, id: &str) -> Result<Option<MigrationCheckpoint>> {
        let path = self.checkpoint_path(id);
        if !path.exists() {
            return Ok(None);
        }

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

    pub fn list(&self) -> Result<Vec<MigrationCheckpoint>> {
        let mut checkpoints = Vec::new();

        if !self.root.exists() {
            return Ok(checkpoints);
        }

        for entry in fs::read_dir(&self.root)? {
            let entry = entry?;
            let path = entry.path();

            if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
                continue;
            }

            if let Ok(content) = fs::read_to_string(&path) {
                if let Ok(checkpoint) = serde_json::from_str::<MigrationCheckpoint>(&content) {
                    checkpoints.push(checkpoint);
                }
            }
        }

        checkpoints.sort_by(|left, right| right.timestamp.cmp(&left.timestamp));
        Ok(checkpoints)
    }

    fn checkpoint_path(&self, id: &str) -> PathBuf {
        self.root.join(format!("{id}.json"))
    }
}

fn generate_session_id() -> String {
    format!("session-{}", current_timestamp_millis())
}

pub fn current_timestamp() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

fn current_timestamp_millis() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis()
}