blue_build/commands/
validate.rs

1use std::{
2    fmt::Write,
3    fs::OpenOptions,
4    io::{BufReader, Read},
5    path::{Path, PathBuf},
6    sync::Arc,
7};
8
9use blue_build_process_management::ASYNC_RUNTIME;
10use blue_build_recipe::{FromFileList, ModuleExt, Recipe, StagesExt};
11use blue_build_utils::constants::{
12    MODULE_STAGE_LIST_V1_SCHEMA_URL, MODULE_V1_SCHEMA_URL, RECIPE_V1_SCHEMA_URL,
13    STAGE_V1_SCHEMA_URL,
14};
15use bon::Builder;
16use clap::Args;
17use colored::Colorize;
18use log::{debug, info, trace};
19use miette::{Context, IntoDiagnostic, Report, bail, miette};
20use rayon::prelude::*;
21use schema_validator::SchemaValidator;
22use serde::de::DeserializeOwned;
23use serde_json::Value;
24
25use super::BlueBuildCommand;
26
27mod location;
28mod schema_validator;
29mod yaml_span;
30
31#[derive(Debug, Args, Builder)]
32pub struct ValidateCommand {
33    /// The path to the recipe.
34    ///
35    /// NOTE: In order for this to work,
36    /// you must be in the root of your
37    /// bluebuild repository.
38    pub recipe: PathBuf,
39
40    /// Display all errors that failed
41    /// validation of the recipe.
42    #[arg(short, long)]
43    #[builder(default)]
44    pub all_errors: bool,
45
46    #[clap(skip)]
47    recipe_validator: Option<SchemaValidator>,
48
49    #[clap(skip)]
50    stage_validator: Option<SchemaValidator>,
51
52    #[clap(skip)]
53    module_validator: Option<SchemaValidator>,
54
55    #[clap(skip)]
56    module_stage_list_validator: Option<SchemaValidator>,
57}
58
59impl BlueBuildCommand for ValidateCommand {
60    fn try_run(&mut self) -> miette::Result<()> {
61        let recipe_path_display = self.recipe.display().to_string().bold().italic();
62
63        if !self.recipe.is_file() {
64            bail!("File {recipe_path_display} must exist");
65        }
66
67        ASYNC_RUNTIME.block_on(self.setup_validators())?;
68
69        if let Err(errors) = self.validate_recipe() {
70            let errors = errors.into_iter().try_fold(
71                String::new(),
72                |mut full, err| -> miette::Result<String> {
73                    write!(&mut full, "{err:?}").into_diagnostic()?;
74                    Ok(full)
75                },
76            )?;
77            let main_err = format!("Recipe {recipe_path_display} failed to validate");
78
79            if self.all_errors {
80                return Err(miette!("{errors}").context(main_err));
81            }
82
83            return Err(miette!(
84                help = format!(
85                    "Use `{}` to view more information.\n{}",
86                    format!("bluebuild validate --all-errors {}", self.recipe.display()).bold(),
87                    format_args!(
88                        "If you're using a local module, be sure to add `{}` to the module entry",
89                        "source: local".bold()
90                    ),
91                ),
92                "{errors}",
93            )
94            .context(main_err));
95        }
96        info!("Recipe {recipe_path_display} is valid");
97
98        Ok(())
99    }
100}
101
102impl ValidateCommand {
103    async fn setup_validators(&mut self) -> Result<(), Report> {
104        let (rv, sv, mv, mslv) = tokio::try_join!(
105            SchemaValidator::builder()
106                .url(RECIPE_V1_SCHEMA_URL)
107                .all_errors(self.all_errors)
108                .build(),
109            SchemaValidator::builder()
110                .url(STAGE_V1_SCHEMA_URL)
111                .all_errors(self.all_errors)
112                .build(),
113            SchemaValidator::builder()
114                .url(MODULE_V1_SCHEMA_URL)
115                .all_errors(self.all_errors)
116                .build(),
117            SchemaValidator::builder()
118                .url(MODULE_STAGE_LIST_V1_SCHEMA_URL)
119                .all_errors(self.all_errors)
120                .build(),
121        )?;
122        self.recipe_validator = Some(rv);
123        self.stage_validator = Some(sv);
124        self.module_validator = Some(mv);
125        self.module_stage_list_validator = Some(mslv);
126        Ok(())
127    }
128
129    fn validate_file<DF>(
130        &self,
131        path: &Path,
132        traversed_files: &[&Path],
133        single_validator: &SchemaValidator,
134    ) -> Vec<Report>
135    where
136        DF: DeserializeOwned + FromFileList,
137    {
138        let path_display = path.display().to_string().bold().italic();
139
140        if traversed_files.contains(&path) {
141            return vec![miette!(
142                "{} File {path_display} has already been parsed:\n{traversed_files:?}",
143                "Circular dependency detected!".bright_red(),
144            )];
145        }
146        let traversed_files = {
147            let mut files: Vec<&Path> = Vec::with_capacity(traversed_files.len() + 1);
148            files.extend_from_slice(traversed_files);
149            files.push(path);
150            files
151        };
152
153        let file_str = match read_file(path) {
154            Err(e) => return vec![e],
155            Ok(f) => Arc::new(f),
156        };
157
158        match serde_yaml::from_str::<Value>(&file_str)
159            .into_diagnostic()
160            .with_context(|| format!("Failed to deserialize file {path_display}"))
161        {
162            Ok(instance) => {
163                trace!("{path_display}:\n{instance}");
164
165                if instance.get(DF::LIST_KEY).is_some() {
166                    debug!("{path_display} is a list file");
167                    let err = self
168                        .module_stage_list_validator
169                        .as_ref()
170                        .unwrap()
171                        .process_validation(path, file_str.clone())
172                        .err();
173
174                    err.map_or_else(
175                        || {
176                            serde_yaml::from_str::<DF>(&file_str)
177                                .into_diagnostic()
178                                .map_or_else(
179                                    |e| vec![e],
180                                    |file| {
181                                        let mut errs = file
182                                            .get_from_file_paths()
183                                            .par_iter()
184                                            .map(|file_path| {
185                                                self.validate_file::<DF>(
186                                                    file_path,
187                                                    &traversed_files,
188                                                    single_validator,
189                                                )
190                                            })
191                                            .flatten()
192                                            .collect::<Vec<_>>();
193                                        errs.extend(
194                                            file.get_module_from_file_paths()
195                                                .par_iter()
196                                                .map(|file_path| {
197                                                    self.validate_file::<ModuleExt>(
198                                                        file_path,
199                                                        &[],
200                                                        self.module_validator.as_ref().unwrap(),
201                                                    )
202                                                })
203                                                .flatten()
204                                                .collect::<Vec<_>>(),
205                                        );
206                                        errs
207                                    },
208                                )
209                        },
210                        |err| vec![err.into()],
211                    )
212                } else {
213                    debug!("{path_display} is a single file file");
214                    single_validator
215                        .process_validation(path, file_str)
216                        .map_or_else(|e| vec![e.into()], |()| Vec::new())
217                }
218            }
219            Err(e) => vec![e],
220        }
221    }
222
223    fn validate_recipe(&self) -> Result<(), Vec<Report>> {
224        let recipe_path_display = self.recipe.display().to_string().bold().italic();
225        debug!("Validating recipe {recipe_path_display}");
226
227        let recipe_str = Arc::new(read_file(&self.recipe).map_err(err_vec)?);
228        let recipe: Value = serde_yaml::from_str(&recipe_str)
229            .into_diagnostic()
230            .with_context(|| format!("Failed to deserialize recipe {recipe_path_display}"))
231            .map_err(err_vec)?;
232        trace!("{recipe_path_display}:\n{recipe}");
233
234        let schema_validator = self.recipe_validator.as_ref().unwrap();
235        let err = schema_validator
236            .process_validation(&self.recipe, recipe_str.clone())
237            .err();
238
239        if let Some(err) = err {
240            Err(vec![err.into()])
241        } else {
242            let recipe: Recipe = serde_yaml::from_str(&recipe_str)
243                .into_diagnostic()
244                .with_context(|| {
245                    format!("Unable to convert Value to Recipe for {recipe_path_display}")
246                })
247                .map_err(err_vec)?;
248
249            let mut errors: Vec<Report> = Vec::new();
250            if let Some(stages) = &recipe.stages_ext {
251                debug!("Validating stages for recipe {recipe_path_display}");
252
253                errors.extend(
254                    stages
255                        .get_from_file_paths()
256                        .par_iter()
257                        .map(|stage_path| {
258                            debug!(
259                                "Found 'from-file' reference in {recipe_path_display} going to {}",
260                                stage_path.display().to_string().italic().bold()
261                            );
262                            self.validate_file::<StagesExt>(
263                                stage_path,
264                                &[],
265                                self.stage_validator.as_ref().unwrap(),
266                            )
267                        })
268                        .flatten()
269                        .collect::<Vec<_>>(),
270                );
271            }
272
273            debug!("Validating modules for recipe {recipe_path_display}");
274            errors.extend(
275                recipe
276                    .modules_ext
277                    .get_from_file_paths()
278                    .par_iter()
279                    .map(|module_path| {
280                        debug!(
281                            "Found 'from-file' reference in {recipe_path_display} going to {}",
282                            module_path.display().to_string().italic().bold()
283                        );
284                        self.validate_file::<ModuleExt>(
285                            module_path,
286                            &[],
287                            self.module_validator.as_ref().unwrap(),
288                        )
289                    })
290                    .flatten()
291                    .collect::<Vec<_>>(),
292            );
293            if errors.is_empty() {
294                Ok(())
295            } else {
296                Err(errors)
297            }
298        }
299    }
300}
301
302fn err_vec(err: Report) -> Vec<Report> {
303    vec![err]
304}
305
306fn read_file(path: &Path) -> Result<String, Report> {
307    let mut recipe = String::new();
308    BufReader::new(
309        OpenOptions::new()
310            .read(true)
311            .open(path)
312            .into_diagnostic()
313            .with_context(|| {
314                format!(
315                    "Unable to open {}",
316                    path.display().to_string().italic().bold()
317                )
318            })?,
319    )
320    .read_to_string(&mut recipe)
321    .into_diagnostic()?;
322    Ok(recipe)
323}