1use crate::config::ConfigFile;
9use crate::error::Error;
10use crate::fs::{
11 catalog_io, content_block_io, custom_attribute_io, email_template_io, try_read_resource_dir,
12};
13use crate::resource::ResourceKind;
14use anyhow::anyhow;
15use clap::Args;
16use regex_lite::Regex;
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20use super::selected_kinds;
21
22#[derive(Args, Debug)]
23pub struct ValidateArgs {
24 #[arg(long, value_enum)]
26 pub resource: Option<ResourceKind>,
27}
28
29#[derive(Debug)]
30struct ValidationIssue {
31 path: PathBuf,
32 message: String,
33}
34
35pub async fn run(args: &ValidateArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
36 let kinds = selected_kinds(args.resource, &cfg.resources);
37
38 let mut issues: Vec<ValidationIssue> = Vec::new();
39
40 for kind in kinds {
41 match kind {
42 ResourceKind::CatalogSchema => {
43 let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
44 validate_catalog_schemas(
45 &catalogs_root,
46 cfg.naming.catalog_name_pattern.as_deref(),
47 &mut issues,
48 )?;
49 }
50 ResourceKind::ContentBlock => {
51 let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
52 validate_content_blocks(
53 &content_blocks_root,
54 cfg.naming.content_block_name_pattern.as_deref(),
55 &mut issues,
56 )?;
57 }
58 ResourceKind::CatalogItems => {
59 let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
60 validate_catalog_items(&catalogs_root, &mut issues)?;
61 }
62 ResourceKind::EmailTemplate => {
63 let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
64 validate_email_templates(&email_templates_root, &mut issues)?;
65 }
66 ResourceKind::CustomAttribute => {
67 let registry_path = config_dir.join(&cfg.resources.custom_attribute.path);
68 validate_custom_attributes(
69 ®istry_path,
70 cfg.naming.custom_attribute_name_pattern.as_deref(),
71 &mut issues,
72 )?;
73 }
74 }
75 }
76
77 if issues.is_empty() {
78 eprintln!("✓ All checks passed.");
79 return Ok(());
80 }
81
82 eprintln!("✗ Validation found {} issue(s):", issues.len());
83 for issue in &issues {
84 eprintln!(" • {}: {}", issue.path.display(), issue.message);
85 }
86
87 Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
88}
89
90fn open_resource_dir(
94 root: &Path,
95 kind_label: &str,
96 issues: &mut Vec<ValidationIssue>,
97) -> anyhow::Result<Option<std::fs::ReadDir>> {
98 match try_read_resource_dir(root, kind_label) {
99 Ok(rd) => Ok(rd),
100 Err(Error::InvalidFormat { path, message }) => {
101 issues.push(ValidationIssue { path, message });
102 Ok(None)
103 }
104 Err(e) => Err(e.into()),
105 }
106}
107
108fn compile_name_pattern(
113 raw: Option<&str>,
114 config_key: &str,
115) -> anyhow::Result<Option<(String, Regex)>> {
116 match raw {
117 Some(p) => Ok(Some((
118 p.to_string(),
119 Regex::new(p).map_err(|e| anyhow!("invalid {config_key} regex {p:?}: {e}"))?,
120 ))),
121 None => Ok(None),
122 }
123}
124
125fn check_name_pattern(
129 pattern: Option<&(String, Regex)>,
130 name: &str,
131 path: &Path,
132 kind_label: &str,
133 config_key: &str,
134 issues: &mut Vec<ValidationIssue>,
135) {
136 let Some((pattern_str, re)) = pattern else {
137 return;
138 };
139 if !re.is_match(name) {
140 issues.push(ValidationIssue {
141 path: path.to_path_buf(),
142 message: format!(
143 "{kind_label} name '{name}' does not match {config_key} '{pattern_str}'"
144 ),
145 });
146 }
147}
148
149fn validate_catalog_schemas(
150 catalogs_root: &Path,
151 name_pattern: Option<&str>,
152 issues: &mut Vec<ValidationIssue>,
153) -> anyhow::Result<()> {
154 let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
155 return Ok(());
156 };
157
158 let pattern = compile_name_pattern(name_pattern, "catalog_name_pattern")?;
159
160 for entry in read_dir {
161 let entry = entry?;
162 if !entry.file_type()?.is_dir() {
163 tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
164 continue;
165 }
166 let dir = entry.path();
167 let schema_path = dir.join("schema.yaml");
168 if !schema_path.is_file() {
169 continue;
170 }
171
172 let cat = match catalog_io::read_schema_file(&schema_path) {
173 Ok(c) => c,
174 Err(e) => {
175 issues.push(ValidationIssue {
176 path: schema_path.clone(),
177 message: format!("parse error: {e}"),
178 });
179 continue;
180 }
181 };
182
183 let dir_name = entry.file_name().to_string_lossy().into_owned();
187 if cat.name != dir_name {
188 issues.push(ValidationIssue {
189 path: schema_path.clone(),
190 message: format!(
191 "catalog name '{}' does not match its directory '{}'",
192 cat.name, dir_name
193 ),
194 });
195 }
196
197 check_name_pattern(
198 pattern.as_ref(),
199 &cat.name,
200 &schema_path,
201 "catalog",
202 "catalog_name_pattern",
203 issues,
204 );
205 }
206
207 Ok(())
208}
209
210fn validate_content_blocks(
211 content_blocks_root: &Path,
212 name_pattern: Option<&str>,
213 issues: &mut Vec<ValidationIssue>,
214) -> anyhow::Result<()> {
215 let Some(read_dir) = open_resource_dir(content_blocks_root, "content_blocks", issues)? else {
216 return Ok(());
217 };
218
219 let pattern = compile_name_pattern(name_pattern, "content_block_name_pattern")?;
220
221 for entry in read_dir {
222 let entry = entry?;
223 let path = entry.path();
224 if !entry.file_type()?.is_file() {
225 tracing::debug!(path = %path.display(), "skipping non-file entry");
226 continue;
227 }
228 if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
229 continue;
230 }
231 let stem = path
232 .file_stem()
233 .and_then(|s| s.to_str())
234 .unwrap_or_default()
235 .to_string();
236
237 let cb = match content_block_io::read_content_block_file(&path) {
238 Ok(cb) => cb,
239 Err(e) => {
240 issues.push(ValidationIssue {
241 path: path.clone(),
242 message: format!("parse error: {e}"),
243 });
244 continue;
245 }
246 };
247
248 if cb.name != stem {
249 issues.push(ValidationIssue {
250 path: path.clone(),
251 message: format!(
252 "content block name '{}' does not match its file stem '{}'",
253 cb.name, stem
254 ),
255 });
256 }
257
258 check_name_pattern(
259 pattern.as_ref(),
260 &cb.name,
261 &path,
262 "content block",
263 "content_block_name_pattern",
264 issues,
265 );
266 }
267
268 Ok(())
269}
270
271fn validate_email_templates(
272 email_templates_root: &Path,
273 issues: &mut Vec<ValidationIssue>,
274) -> anyhow::Result<()> {
275 let Some(read_dir) = open_resource_dir(email_templates_root, "email_templates", issues)? else {
276 return Ok(());
277 };
278
279 for entry in read_dir {
280 let entry = entry?;
281 let path = entry.path();
282 if !entry.file_type()?.is_dir() {
283 tracing::debug!(path = %path.display(), "skipping non-directory entry");
284 continue;
285 }
286 let template_yaml_path = path.join("template.yaml");
287 if !template_yaml_path.is_file() {
288 continue;
289 }
290 let dir_name = entry.file_name().to_string_lossy().into_owned();
291
292 let et = match email_template_io::read_email_template_dir(&path) {
293 Ok(et) => et,
294 Err(e) => {
295 issues.push(ValidationIssue {
296 path: template_yaml_path.clone(),
297 message: format!("parse error: {e}"),
298 });
299 continue;
300 }
301 };
302
303 if et.name != dir_name {
304 issues.push(ValidationIssue {
305 path: template_yaml_path.clone(),
306 message: format!(
307 "email template name '{}' does not match its directory '{}'",
308 et.name, dir_name
309 ),
310 });
311 }
312
313 if et.subject.is_empty() {
314 issues.push(ValidationIssue {
315 path: template_yaml_path.clone(),
316 message: format!("email template '{}' has an empty subject", et.name),
317 });
318 }
319 }
320
321 Ok(())
322}
323
324fn validate_catalog_items(
325 catalogs_root: &Path,
326 issues: &mut Vec<ValidationIssue>,
327) -> anyhow::Result<()> {
328 let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
329 return Ok(());
330 };
331
332 for entry in read_dir {
333 let entry = entry?;
334 if !entry.file_type()?.is_dir() {
335 continue;
336 }
337 let dir = entry.path();
338 let items_path = dir.join(catalog_io::ITEMS_FILE_NAME);
339 if !items_path.is_file() {
340 continue;
341 }
342
343 let (catalog_name, csv_columns) = match catalog_io::read_item_csv_columns(&items_path) {
345 Ok(result) => result,
346 Err(e) => {
347 issues.push(ValidationIssue {
348 path: items_path.clone(),
349 message: format!("parse error: {e}"),
350 });
351 continue;
352 }
353 };
354
355 let schema_path = dir.join("schema.yaml");
357 if schema_path.is_file() {
358 let schema = match catalog_io::read_schema_file(&schema_path) {
359 Ok(s) => s,
360 Err(e) => {
361 issues.push(ValidationIssue {
362 path: schema_path.clone(),
363 message: format!("cannot parse schema.yaml: {e}"),
364 });
365 continue;
366 }
367 };
368 let schema_field_names: std::collections::BTreeSet<&str> =
369 schema.fields.iter().map(|f| f.name.as_str()).collect();
370 let csv_field_names: std::collections::BTreeSet<&str> =
371 csv_columns.iter().map(String::as_str).collect();
372
373 for col in &csv_field_names {
374 if !schema_field_names.contains(col) {
375 issues.push(ValidationIssue {
376 path: items_path.clone(),
377 message: format!(
378 "CSV column '{}' is not in schema for catalog '{}'",
379 col, catalog_name
380 ),
381 });
382 }
383 }
384 for field in &schema_field_names {
385 if !csv_field_names.contains(field) {
386 issues.push(ValidationIssue {
387 path: items_path.clone(),
388 message: format!(
389 "schema field '{}' is missing from CSV for catalog '{}'",
390 field, catalog_name
391 ),
392 });
393 }
394 }
395 }
396 }
397
398 Ok(())
399}
400
401fn validate_custom_attributes(
402 registry_path: &Path,
403 name_pattern: Option<&str>,
404 issues: &mut Vec<ValidationIssue>,
405) -> anyhow::Result<()> {
406 let registry = match custom_attribute_io::load_registry(registry_path) {
407 Ok(Some(r)) => r,
408 Ok(None) => return Ok(()),
409 Err(Error::YamlParse { path, source }) => {
410 issues.push(ValidationIssue {
411 path,
412 message: format!("parse error: {source}"),
413 });
414 return Ok(());
415 }
416 Err(e) => return Err(e.into()),
417 };
418
419 let pattern = compile_name_pattern(name_pattern, "custom_attribute_name_pattern")?;
420
421 let mut seen = HashSet::with_capacity(registry.attributes.len());
422 for attr in ®istry.attributes {
423 if !seen.insert(attr.name.as_str()) {
424 issues.push(ValidationIssue {
425 path: registry_path.to_path_buf(),
426 message: format!("duplicate custom attribute name '{}'", attr.name),
427 });
428 }
429
430 check_name_pattern(
431 pattern.as_ref(),
432 &attr.name,
433 registry_path,
434 "custom attribute",
435 "custom_attribute_name_pattern",
436 issues,
437 );
438 }
439
440 Ok(())
441}