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.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}