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 pub recipe: PathBuf,
39
40 #[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}