morph-cli 0.1.0

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

use crate::core::recipe::{DetectionReport, FileAnalysis, Recipe, TransformReport};

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PipelineContext {
    pub project_root: PathBuf,
    pub files: HashMap<PathBuf, FilePipelineState>,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FilePipelineState {
    pub original_path: PathBuf,
    pub current_content: Option<String>,
    pub stages_passed: Vec<String>,
    pub stages_failed: Vec<FailedStage>,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FailedStage {
    pub recipe: String,
    pub error: String,
}

impl PipelineContext {
    pub fn new(project_root: PathBuf) -> Self {
        Self {
            project_root,
            files: HashMap::new(),
        }
    }

    pub fn add_file(&mut self, path: PathBuf, _analysis: FileAnalysis) {
        self.files.insert(
            path.clone(),
            FilePipelineState {
                original_path: path,
                current_content: None,
                stages_passed: Vec::new(),
                stages_failed: Vec::new(),
            },
        );
    }

    pub fn mark_stage_passed(&mut self, path: &Path, recipe: &str) {
        if let Some(state) = self.files.get_mut(path) {
            state.stages_passed.push(recipe.to_string());
        }
    }

    #[allow(dead_code)]
    pub fn mark_stage_failed(&mut self, path: &Path, recipe: &str, error: &str) {
        if let Some(state) = self.files.get_mut(path) {
            state.stages_failed.push(FailedStage {
                recipe: recipe.to_string(),
                error: error.to_string(),
            });
        }
    }

    #[allow(dead_code)]
    pub fn get_files_at_stage(&self, stage: &str) -> Vec<&PathBuf> {
        self.files
            .iter()
            .filter(|(_, state)| state.stages_passed.contains(&stage.to_string()))
            .map(|(path, _)| path)
            .collect()
    }

    #[allow(dead_code)]
    pub fn get_files_without_stage(&self, stage: &str) -> Vec<&PathBuf> {
        self.files
            .iter()
            .filter(|(_, state)| !state.stages_passed.contains(&stage.to_string()))
            .map(|(path, _)| path)
            .collect()
    }
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PipelineStage {
    pub recipe_name: &'static str,
    pub detect_result: Option<DetectionReport>,
    pub transform_result: Option<TransformReport>,
    pub timing: StageTiming,
}

#[derive(Debug, Clone, Default)]
pub struct StageTiming {
    pub detect_ms: u64,
    pub transform_ms: u64,
    pub total_ms: u64,
}

#[derive(Debug, Clone)]
pub struct RecipeDependency {
    /// Recipe name that must appear after `before`.
    pub after: String,
    /// Recipe name that must appear before `after`.
    pub before: String,
}

pub struct RecipeOrderer {
    pub(crate) dependencies: Vec<RecipeDependency>,
}

impl RecipeOrderer {
    pub fn new() -> Self {
        Self {
            dependencies: Vec::new(),
        }
    }

    /// Build an orderer by reading `should_run_before` / `should_run_after` hints
    /// from all recipes in the supplied slice. This is a O(n²) pass but n is tiny.
    pub fn from_metadata(recipes: &[&dyn Recipe]) -> Self {
        let mut orderer = Self::new();
        for recipe in recipes {
            let meta = recipe.metadata();
            // should_run_before X  →  this recipe (meta.name) is the "after" of X
            for before in meta.should_run_before {
                orderer.dependencies.push(RecipeDependency {
                    after: meta.name.to_string(),
                    before: (*before).to_string(),
                });
            }
            // should_run_after Y  →  Y is the "before", meta.name is the "after"
            for after in meta.should_run_after {
                orderer.dependencies.push(RecipeDependency {
                    after: meta.name.to_string(),
                    before: (*after).to_string(),
                });
            }
        }
        orderer
    }

    #[allow(dead_code)]
    pub fn add_dependency_str(mut self, after: impl Into<String>, before: impl Into<String>) -> Self {
        self.dependencies.push(RecipeDependency {
            after: after.into(),
            before: before.into(),
        });
        self
    }

    /// Legacy static-str helper kept for existing call-sites in tests.
    #[allow(dead_code)]
    pub fn add_dependency(mut self, after: &'static str, before: &'static str) -> Self {
        self.dependencies.push(RecipeDependency {
            after: after.to_string(),
            before: before.to_string(),
        });
        self
    }

    pub fn order<'a>(&self, recipes: &'a [&'a dyn Recipe]) -> Vec<&'a dyn Recipe> {
        let mut ordered = Vec::from(recipes);

        for dep in &self.dependencies {
            let after_idx = ordered.iter().position(|r| r.metadata().name == dep.after);
            let before_idx = ordered.iter().position(|r| r.metadata().name == dep.before);
            if let (Some(ai), Some(bi)) = (after_idx, before_idx) {
                if ai > bi {
                    // already correct order; if reversed, swap
                } else if ai < bi {
                    // `after` appears before `before` — swap to enforce ordering
                    ordered.swap(ai, bi);
                }
            }
        }

        ordered
    }
}

impl Default for RecipeOrderer {
    fn default() -> Self {
        Self::new()
    }
}


#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)]
pub enum IncompatibilityReason {
    DuplicateRecipe,
    IncompatibleRecipe,
    MissingRequiredRecipe,
    RequiredRecipeOutOfOrder,
    /// Soft ordering hint violated (should_run_before / should_run_after).
    OrderingHintViolation,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct IncompatibleRecipe {
    pub recipe_a: String,
    pub recipe_b: String,
    pub reason: IncompatibilityReason,
}

pub fn validate_recipe_order(recipes: &[&dyn Recipe]) -> Vec<IncompatibleRecipe> {
    let mut incompatibilities = Vec::new();
    let recipe_names: Vec<_> = recipes
        .iter()
        .map(|recipe| recipe.metadata().name)
        .collect();

    for (index, recipe) in recipes.iter().enumerate() {
        let metadata = recipe.metadata();

        if recipe_names[..index].contains(&metadata.name) {
            incompatibilities.push(IncompatibleRecipe {
                recipe_a: metadata.name.to_string(),
                recipe_b: metadata.name.to_string(),
                reason: IncompatibilityReason::DuplicateRecipe,
            });
        }

        for required in metadata.required_recipes {
            match recipe_names.iter().position(|name| name == required) {
                Some(required_index) if required_index < index => {}
                Some(_) => incompatibilities.push(IncompatibleRecipe {
                    recipe_a: metadata.name.to_string(),
                    recipe_b: (*required).to_string(),
                    reason: IncompatibilityReason::RequiredRecipeOutOfOrder,
                }),
                None => incompatibilities.push(IncompatibleRecipe {
                    recipe_a: metadata.name.to_string(),
                    recipe_b: (*required).to_string(),
                    reason: IncompatibilityReason::MissingRequiredRecipe,
                }),
            }
        }

        for incompatible in metadata.incompatible_recipes {
            if recipe_names.contains(incompatible) {
                incompatibilities.push(IncompatibleRecipe {
                    recipe_a: metadata.name.to_string(),
                    recipe_b: (*incompatible).to_string(),
                    reason: IncompatibilityReason::IncompatibleRecipe,
                });
            }
        }

        // Soft ordering hints: should_run_before
        for before_recipe in metadata.should_run_before {
            if let Some(before_idx) = recipe_names.iter().position(|n| n == before_recipe) {
                if before_idx < index {
                    // This recipe appears after a recipe it declares it should run before.
                    incompatibilities.push(IncompatibleRecipe {
                        recipe_a: metadata.name.to_string(),
                        recipe_b: (*before_recipe).to_string(),
                        reason: IncompatibilityReason::OrderingHintViolation,
                    });
                }
            }
        }

        // Soft ordering hints: should_run_after
        for after_recipe in metadata.should_run_after {
            if let Some(after_idx) = recipe_names.iter().position(|n| n == after_recipe) {
                if after_idx > index {
                    // This recipe appears before a recipe it declares it should run after.
                    incompatibilities.push(IncompatibleRecipe {
                        recipe_a: metadata.name.to_string(),
                        recipe_b: (*after_recipe).to_string(),
                        reason: IncompatibilityReason::OrderingHintViolation,
                    });
                }
            }
        }
    }

    incompatibilities
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum ExecutionEvent {
    StageStarted {
        recipe: String,
    },
    StageCompleted {
        recipe: String,
        duration_ms: u64,
    },
    StageFailed {
        recipe: String,
        error: String,
    },
    FileProcessed {
        path: PathBuf,
        recipe: String,
        success: bool,
    },
    StageSkipped {
        recipe: String,
        reason: String,
    },
}