use std::{collections::HashMap, ffi::OsStr, fs::File, path::Path, result}; use scoped_tls_hkt::scoped_thread_local; use serde::{de::DeserializeOwned, Deserialize, Deserializer}; use walkdir::WalkDir; use common::{error::*, types::*}; use crate::task::{TaskDef, TaskMeta}; scoped_thread_local!(static CURRENT_RECIPE: str); fn current_recipe() -> String { CURRENT_RECIPE.with(|current| current.to_string()) } pub fn deserialize_task_id<'de, D>(deserializer: D) -> result::Result where D: Deserializer<'de>, { #[derive(Deserialize)] struct RecipeTaskID { recipe: Option, task: String, } let RecipeTaskID { recipe, task } = RecipeTaskID::deserialize(deserializer)?; Ok(TaskID { recipe: recipe.unwrap_or_else(current_recipe), task, }) } #[derive(Clone, Debug, Deserialize, Default)] pub struct RecipeMeta { pub name: Option, pub version: Option, } #[derive(Debug, Deserialize)] struct Recipe { #[serde(default)] pub meta: RecipeMeta, pub tasks: HashMap, } #[derive(Debug, Deserialize)] struct Subrecipe { pub tasks: HashMap, } fn read_yaml(path: &Path) -> Result { let f = File::open(path).context("IO error")?; let value: T = serde_yaml::from_reader(f) .map_err(Error::new) .context("YAML error")?; Ok(value) } const RECIPE_NAME: &str = "build"; const RECIPE_PREFIX: &str = "build."; fn recipe_name(path: &Path) -> Option<&str> { if path.extension() != Some("yml".as_ref()) { return None; } let stem = path.file_stem()?.to_str()?; if stem == RECIPE_NAME { return Some(""); } stem.strip_prefix(RECIPE_PREFIX) } fn handle_recipe_tasks( tasks: &mut HashMap>, recipe_tasks: HashMap, meta: &TaskMeta, ) { for (label, mut task) in recipe_tasks { let task_id = TaskID { recipe: meta.recipe.clone(), task: label, }; task.meta = meta.clone(); tasks.entry(task_id).or_default().push(task); } } fn read_recipe_tasks( path: &Path, basename: &str, tasks: &mut HashMap>, ) -> Result { let recipe_def = CURRENT_RECIPE.set(basename, || read_yaml::(path))?; let name = recipe_def .meta .name .as_deref() .unwrap_or(basename) .to_string(); let meta = TaskMeta { basename: basename.to_string(), recipename: "".to_string(), recipe: basename.to_string(), name, version: recipe_def.meta.version.clone(), }; handle_recipe_tasks(tasks, recipe_def.tasks, &meta); Ok(recipe_def.meta) } fn read_subrecipe_tasks( path: &Path, basename: &str, recipename: &str, recipe_meta: &RecipeMeta, tasks: &mut HashMap>, ) -> Result<()> { let recipe = format!("{basename}/{recipename}"); let recipe_def = CURRENT_RECIPE.set(&recipe, || read_yaml::(path))?; let name = recipe_meta.name.as_deref().unwrap_or(basename).to_string(); let meta = TaskMeta { basename: basename.to_string(), recipename: recipename.to_string(), recipe: recipe.clone(), name, version: recipe_meta.version.clone(), }; handle_recipe_tasks(tasks, recipe_def.tasks, &meta); Ok(()) } pub fn read_recipes>(path: P) -> Result>> { let mut tasks = HashMap::>::new(); let mut recipe_metas = HashMap::::new(); for entry in WalkDir::new(path) .sort_by(|a, b| { // Files are sorted first by stem, then by extension, so that // recipe.yml will always be read before recipe.NAME.yml let stem_cmp = a.path().file_stem().cmp(&b.path().file_stem()); let ext_cmp = a.path().extension().cmp(&b.path().extension()); stem_cmp.then(ext_cmp) }) .into_iter() .filter_map(|e| e.ok()) { let path = entry.path(); if !path.is_file() { continue; } let Some(recipename) = recipe_name(path) else { continue; }; let Some(basename) = path .parent() .and_then(Path::file_name) .and_then(OsStr::to_str) else { continue; }; if recipename.is_empty() { recipe_metas.insert( basename.to_string(), read_recipe_tasks(path, basename, &mut tasks)?, ); } else { let Some(recipe_meta) = recipe_metas.get(basename) else { continue; }; read_subrecipe_tasks(path, basename, recipename, recipe_meta, &mut tasks)?; } } Ok(tasks) }