Skip to main content

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
68            .block_on(self.setup_validators())
69            .wrap_err("Failed to setup validators")?;
70
71        if let Err(errors) = self.validate_recipe() {
72            let errors = errors.into_iter().try_fold(
73                String::new(),
74                |mut full, err| -> miette::Result<String> {
75                    write!(&mut full, "{err:?}").into_diagnostic()?;
76                    Ok(full)
77                },
78            )?;
79            let main_err = format!("Recipe {recipe_path_display} failed to validate");
80
81            if self.all_errors {
82                return Err(miette!("{errors}").context(main_err));
83            }
84
85            return Err(miette!(
86                help = format!(
87                    "Use `{}` to view more information.\n{}",
88                    format!("bluebuild validate --all-errors {}", self.recipe.display()).bold(),
89                    format_args!(
90                        "If you're using a local module, be sure to add `{}` to the module entry",
91                        "source: local".bold()
92                    ),
93                ),
94                "{errors}",
95            )
96            .context(main_err));
97        }
98        info!("Recipe {recipe_path_display} is valid");
99
100        Ok(())
101    }
102}
103
104impl ValidateCommand {
105    async fn setup_validators(&mut self) -> Result<(), Report> {
106        let (rv, sv, mv, mslv) = tokio::try_join!(
107            SchemaValidator::builder()
108                .url(RECIPE_V1_SCHEMA_URL)
109                .all_errors(self.all_errors)
110                .build(),
111            SchemaValidator::builder()
112                .url(STAGE_V1_SCHEMA_URL)
113                .all_errors(self.all_errors)
114                .build(),
115            SchemaValidator::builder()
116                .url(MODULE_V1_SCHEMA_URL)
117                .all_errors(self.all_errors)
118                .build(),
119            SchemaValidator::builder()
120                .url(MODULE_STAGE_LIST_V1_SCHEMA_URL)
121                .all_errors(self.all_errors)
122                .build(),
123        )?;
124        self.recipe_validator = Some(rv);
125        self.stage_validator = Some(sv);
126        self.module_validator = Some(mv);
127        self.module_stage_list_validator = Some(mslv);
128        Ok(())
129    }
130
131    fn validate_file<DF>(
132        &self,
133        path: &Path,
134        traversed_files: &[&Path],
135        single_validator: &SchemaValidator,
136    ) -> Vec<Report>
137    where
138        DF: DeserializeOwned + FromFileList,
139    {
140        let path_display = path.display().to_string().bold().italic();
141
142        if traversed_files.contains(&path) {
143            return vec![miette!(
144                "{} File {path_display} has already been parsed:\n{traversed_files:?}",
145                "Circular dependency detected!".bright_red(),
146            )];
147        }
148        let traversed_files = {
149            let mut files: Vec<&Path> = Vec::with_capacity(traversed_files.len() + 1);
150            files.extend_from_slice(traversed_files);
151            files.push(path);
152            files
153        };
154
155        let file_str = match read_file(path) {
156            Err(e) => return vec![e],
157            Ok(f) => Arc::new(f),
158        };
159
160        match serde_yaml::from_str::<Value>(&file_str)
161            .into_diagnostic()
162            .with_context(|| format!("Failed to deserialize file {path_display}"))
163        {
164            Ok(instance) => {
165                trace!("{path_display}:\n{instance}");
166
167                if instance.get(DF::LIST_KEY).is_some() {
168                    debug!("{path_display} is a list file");
169                    let err = self
170                        .module_stage_list_validator
171                        .as_ref()
172                        .unwrap()
173                        .process_validation(path, file_str.clone())
174                        .err();
175
176                    err.map_or_else(
177                        || {
178                            serde_yaml::from_str::<DF>(&file_str)
179                                .into_diagnostic()
180                                .map_or_else(
181                                    |e| vec![e],
182                                    |file| {
183                                        let mut errs = file
184                                            .get_from_file_paths()
185                                            .par_iter()
186                                            .map(|file_path| {
187                                                self.validate_file::<DF>(
188                                                    file_path,
189                                                    &traversed_files,
190                                                    single_validator,
191                                                )
192                                            })
193                                            .flatten()
194                                            .collect::<Vec<_>>();
195                                        errs.extend(
196                                            file.get_module_from_file_paths()
197                                                .par_iter()
198                                                .map(|file_path| {
199                                                    self.validate_file::<ModuleExt>(
200                                                        file_path,
201                                                        &[],
202                                                        self.module_validator.as_ref().unwrap(),
203                                                    )
204                                                })
205                                                .flatten()
206                                                .collect::<Vec<_>>(),
207                                        );
208                                        errs
209                                    },
210                                )
211                        },
212                        |err| vec![err.into()],
213                    )
214                } else {
215                    debug!("{path_display} is a single file file");
216                    single_validator
217                        .process_validation(path, file_str)
218                        .map_or_else(|e| vec![e.into()], |()| Vec::new())
219                }
220            }
221            Err(e) => vec![e],
222        }
223    }
224
225    fn validate_recipe(&self) -> Result<(), Vec<Report>> {
226        let recipe_path_display = self.recipe.display().to_string().bold().italic();
227        debug!("Validating recipe {recipe_path_display}");
228
229        let recipe_str = Arc::new(read_file(&self.recipe).map_err(err_vec)?);
230        let recipe: Value = serde_yaml::from_str(&recipe_str)
231            .into_diagnostic()
232            .with_context(|| format!("Failed to deserialize recipe {recipe_path_display}"))
233            .map_err(err_vec)?;
234        trace!("{recipe_path_display}:\n{recipe}");
235
236        let schema_validator = self.recipe_validator.as_ref().unwrap();
237        let err = schema_validator
238            .process_validation(&self.recipe, recipe_str.clone())
239            .err();
240
241        if let Some(err) = err {
242            Err(vec![err.into()])
243        } else {
244            let recipe: Recipe = serde_yaml::from_str(&recipe_str)
245                .into_diagnostic()
246                .with_context(|| {
247                    format!("Unable to convert Value to Recipe for {recipe_path_display}")
248                })
249                .map_err(err_vec)?;
250
251            let mut errors: Vec<Report> = Vec::new();
252            if let Some(stages) = &recipe.stages_ext {
253                debug!("Validating stages for recipe {recipe_path_display}");
254
255                errors.extend(
256                    stages
257                        .get_from_file_paths()
258                        .par_iter()
259                        .map(|stage_path| {
260                            debug!(
261                                "Found 'from-file' reference in {recipe_path_display} going to {}",
262                                stage_path.display().to_string().italic().bold()
263                            );
264                            self.validate_file::<StagesExt>(
265                                stage_path,
266                                &[],
267                                self.stage_validator.as_ref().unwrap(),
268                            )
269                        })
270                        .flatten()
271                        .collect::<Vec<_>>(),
272                );
273            }
274
275            debug!("Validating modules for recipe {recipe_path_display}");
276            errors.extend(
277                recipe
278                    .modules_ext
279                    .get_from_file_paths()
280                    .par_iter()
281                    .map(|module_path| {
282                        debug!(
283                            "Found 'from-file' reference in {recipe_path_display} going to {}",
284                            module_path.display().to_string().italic().bold()
285                        );
286                        self.validate_file::<ModuleExt>(
287                            module_path,
288                            &[],
289                            self.module_validator.as_ref().unwrap(),
290                        )
291                    })
292                    .flatten()
293                    .collect::<Vec<_>>(),
294            );
295            if errors.is_empty() {
296                Ok(())
297            } else {
298                Err(errors)
299            }
300        }
301    }
302}
303
304fn err_vec(err: Report) -> Vec<Report> {
305    vec![err]
306}
307
308fn read_file(path: &Path) -> Result<String, Report> {
309    let mut recipe = String::new();
310    BufReader::new(
311        OpenOptions::new()
312            .read(true)
313            .open(path)
314            .into_diagnostic()
315            .with_context(|| {
316                format!(
317                    "Unable to open {}",
318                    path.display().to_string().italic().bold()
319                )
320            })?,
321    )
322    .read_to_string(&mut recipe)
323    .into_diagnostic()?;
324    Ok(recipe)
325}