Skip to main content

morph_cli/core/pipeline/
context.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::core::recipe::{DetectionReport, FileAnalysis, Recipe, TransformReport};
5
6#[derive(Debug, Clone)]
7#[allow(dead_code)]
8pub struct PipelineContext {
9    pub project_root: PathBuf,
10    pub files: HashMap<PathBuf, FilePipelineState>,
11}
12
13#[derive(Debug, Clone)]
14#[allow(dead_code)]
15pub struct FilePipelineState {
16    pub original_path: PathBuf,
17    pub current_content: Option<String>,
18    pub stages_passed: Vec<String>,
19    pub stages_failed: Vec<FailedStage>,
20}
21
22#[derive(Debug, Clone)]
23#[allow(dead_code)]
24pub struct FailedStage {
25    pub recipe: String,
26    pub error: String,
27}
28
29impl PipelineContext {
30    pub fn new(project_root: PathBuf) -> Self {
31        Self {
32            project_root,
33            files: HashMap::new(),
34        }
35    }
36
37    pub fn add_file(&mut self, path: PathBuf, _analysis: FileAnalysis) {
38        self.files.insert(
39            path.clone(),
40            FilePipelineState {
41                original_path: path,
42                current_content: None,
43                stages_passed: Vec::new(),
44                stages_failed: Vec::new(),
45            },
46        );
47    }
48
49    pub fn mark_stage_passed(&mut self, path: &Path, recipe: &str) {
50        if let Some(state) = self.files.get_mut(path) {
51            state.stages_passed.push(recipe.to_string());
52        }
53    }
54
55    #[allow(dead_code)]
56    pub fn mark_stage_failed(&mut self, path: &Path, recipe: &str, error: &str) {
57        if let Some(state) = self.files.get_mut(path) {
58            state.stages_failed.push(FailedStage {
59                recipe: recipe.to_string(),
60                error: error.to_string(),
61            });
62        }
63    }
64
65    #[allow(dead_code)]
66    pub fn get_files_at_stage(&self, stage: &str) -> Vec<&PathBuf> {
67        self.files
68            .iter()
69            .filter(|(_, state)| state.stages_passed.contains(&stage.to_string()))
70            .map(|(path, _)| path)
71            .collect()
72    }
73
74    #[allow(dead_code)]
75    pub fn get_files_without_stage(&self, stage: &str) -> Vec<&PathBuf> {
76        self.files
77            .iter()
78            .filter(|(_, state)| !state.stages_passed.contains(&stage.to_string()))
79            .map(|(path, _)| path)
80            .collect()
81    }
82}
83
84#[derive(Debug, Clone)]
85#[allow(dead_code)]
86pub struct PipelineStage {
87    pub recipe_name: &'static str,
88    pub detect_result: Option<DetectionReport>,
89    pub transform_result: Option<TransformReport>,
90    pub timing: StageTiming,
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct StageTiming {
95    pub detect_ms: u64,
96    pub transform_ms: u64,
97    pub total_ms: u64,
98}
99
100#[derive(Debug, Clone)]
101pub struct RecipeDependency {
102    /// Recipe name that must appear after `before`.
103    pub after: String,
104    /// Recipe name that must appear before `after`.
105    pub before: String,
106}
107
108pub struct RecipeOrderer {
109    pub(crate) dependencies: Vec<RecipeDependency>,
110}
111
112impl RecipeOrderer {
113    pub fn new() -> Self {
114        Self {
115            dependencies: Vec::new(),
116        }
117    }
118
119    /// Build an orderer by reading `should_run_before` / `should_run_after` hints
120    /// from all recipes in the supplied slice. This is a O(n²) pass but n is tiny.
121    pub fn from_metadata(recipes: &[&dyn Recipe]) -> Self {
122        let mut orderer = Self::new();
123        for recipe in recipes {
124            let meta = recipe.metadata();
125            // should_run_before X  →  this recipe (meta.name) is the "after" of X
126            for before in meta.should_run_before {
127                orderer.dependencies.push(RecipeDependency {
128                    after: meta.name.to_string(),
129                    before: (*before).to_string(),
130                });
131            }
132            // should_run_after Y  →  Y is the "before", meta.name is the "after"
133            for after in meta.should_run_after {
134                orderer.dependencies.push(RecipeDependency {
135                    after: meta.name.to_string(),
136                    before: (*after).to_string(),
137                });
138            }
139        }
140        orderer
141    }
142
143    #[allow(dead_code)]
144    pub fn add_dependency_str(mut self, after: impl Into<String>, before: impl Into<String>) -> Self {
145        self.dependencies.push(RecipeDependency {
146            after: after.into(),
147            before: before.into(),
148        });
149        self
150    }
151
152    /// Legacy static-str helper kept for existing call-sites in tests.
153    #[allow(dead_code)]
154    pub fn add_dependency(mut self, after: &'static str, before: &'static str) -> Self {
155        self.dependencies.push(RecipeDependency {
156            after: after.to_string(),
157            before: before.to_string(),
158        });
159        self
160    }
161
162    pub fn order<'a>(&self, recipes: &'a [&'a dyn Recipe]) -> Vec<&'a dyn Recipe> {
163        let mut ordered = Vec::from(recipes);
164
165        for dep in &self.dependencies {
166            let after_idx = ordered.iter().position(|r| r.metadata().name == dep.after);
167            let before_idx = ordered.iter().position(|r| r.metadata().name == dep.before);
168            if let (Some(ai), Some(bi)) = (after_idx, before_idx) {
169                if ai > bi {
170                    // already correct order; if reversed, swap
171                } else if ai < bi {
172                    // `after` appears before `before` — swap to enforce ordering
173                    ordered.swap(ai, bi);
174                }
175            }
176        }
177
178        ordered
179    }
180}
181
182impl Default for RecipeOrderer {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190#[allow(dead_code)]
191pub enum IncompatibilityReason {
192    DuplicateRecipe,
193    IncompatibleRecipe,
194    MissingRequiredRecipe,
195    RequiredRecipeOutOfOrder,
196    /// Soft ordering hint violated (should_run_before / should_run_after).
197    OrderingHintViolation,
198}
199
200#[derive(Debug, Clone)]
201#[allow(dead_code)]
202pub struct IncompatibleRecipe {
203    pub recipe_a: String,
204    pub recipe_b: String,
205    pub reason: IncompatibilityReason,
206}
207
208pub fn validate_recipe_order(recipes: &[&dyn Recipe]) -> Vec<IncompatibleRecipe> {
209    let mut incompatibilities = Vec::new();
210    let recipe_names: Vec<_> = recipes
211        .iter()
212        .map(|recipe| recipe.metadata().name)
213        .collect();
214
215    for (index, recipe) in recipes.iter().enumerate() {
216        let metadata = recipe.metadata();
217
218        if recipe_names[..index].contains(&metadata.name) {
219            incompatibilities.push(IncompatibleRecipe {
220                recipe_a: metadata.name.to_string(),
221                recipe_b: metadata.name.to_string(),
222                reason: IncompatibilityReason::DuplicateRecipe,
223            });
224        }
225
226        for required in metadata.required_recipes {
227            match recipe_names.iter().position(|name| name == required) {
228                Some(required_index) if required_index < index => {}
229                Some(_) => incompatibilities.push(IncompatibleRecipe {
230                    recipe_a: metadata.name.to_string(),
231                    recipe_b: (*required).to_string(),
232                    reason: IncompatibilityReason::RequiredRecipeOutOfOrder,
233                }),
234                None => incompatibilities.push(IncompatibleRecipe {
235                    recipe_a: metadata.name.to_string(),
236                    recipe_b: (*required).to_string(),
237                    reason: IncompatibilityReason::MissingRequiredRecipe,
238                }),
239            }
240        }
241
242        for incompatible in metadata.incompatible_recipes {
243            if recipe_names.contains(incompatible) {
244                incompatibilities.push(IncompatibleRecipe {
245                    recipe_a: metadata.name.to_string(),
246                    recipe_b: (*incompatible).to_string(),
247                    reason: IncompatibilityReason::IncompatibleRecipe,
248                });
249            }
250        }
251
252        // Soft ordering hints: should_run_before
253        for before_recipe in metadata.should_run_before {
254            if let Some(before_idx) = recipe_names.iter().position(|n| n == before_recipe) {
255                if before_idx < index {
256                    // This recipe appears after a recipe it declares it should run before.
257                    incompatibilities.push(IncompatibleRecipe {
258                        recipe_a: metadata.name.to_string(),
259                        recipe_b: (*before_recipe).to_string(),
260                        reason: IncompatibilityReason::OrderingHintViolation,
261                    });
262                }
263            }
264        }
265
266        // Soft ordering hints: should_run_after
267        for after_recipe in metadata.should_run_after {
268            if let Some(after_idx) = recipe_names.iter().position(|n| n == after_recipe) {
269                if after_idx > index {
270                    // This recipe appears before a recipe it declares it should run after.
271                    incompatibilities.push(IncompatibleRecipe {
272                        recipe_a: metadata.name.to_string(),
273                        recipe_b: (*after_recipe).to_string(),
274                        reason: IncompatibilityReason::OrderingHintViolation,
275                    });
276                }
277            }
278        }
279    }
280
281    incompatibilities
282}
283
284#[derive(Debug, Clone)]
285#[allow(dead_code)]
286pub enum ExecutionEvent {
287    StageStarted {
288        recipe: String,
289    },
290    StageCompleted {
291        recipe: String,
292        duration_ms: u64,
293    },
294    StageFailed {
295        recipe: String,
296        error: String,
297    },
298    FileProcessed {
299        path: PathBuf,
300        recipe: String,
301        success: bool,
302    },
303    StageSkipped {
304        recipe: String,
305        reason: String,
306    },
307}