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 let excludes = compile_kind_excludes(cfg, kind)?;
45 validate_catalog_schemas(
46 &catalogs_root,
47 cfg.naming.catalog_name_pattern.as_deref(),
48 &excludes,
49 &mut issues,
50 )?;
51 }
52 ResourceKind::ContentBlock => {
53 let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
54 let excludes = compile_kind_excludes(cfg, kind)?;
55 validate_content_blocks(
56 &content_blocks_root,
57 cfg.naming.content_block_name_pattern.as_deref(),
58 &excludes,
59 &mut issues,
60 )?;
61 }
62 ResourceKind::EmailTemplate => {
63 let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
64 let excludes = compile_kind_excludes(cfg, kind)?;
65 validate_email_templates(&email_templates_root, &excludes, &mut issues)?;
66 }
67 ResourceKind::CustomAttribute => {
68 let registry_path = config_dir.join(&cfg.resources.custom_attribute.path);
69 let excludes = compile_kind_excludes(cfg, kind)?;
70 validate_custom_attributes(
71 ®istry_path,
72 cfg.naming.custom_attribute_name_pattern.as_deref(),
73 &excludes,
74 &mut issues,
75 )?;
76 }
77 }
78 }
79
80 if issues.is_empty() {
81 eprintln!("✓ All checks passed.");
82 return Ok(());
83 }
84
85 eprintln!("✗ Validation found {} issue(s):", issues.len());
86 for issue in &issues {
87 eprintln!(" • {}: {}", issue.path.display(), issue.message);
88 }
89
90 Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
91}
92
93fn compile_kind_excludes(cfg: &ConfigFile, kind: ResourceKind) -> anyhow::Result<Vec<Regex>> {
94 Ok(crate::config::compile_exclude_patterns(
95 &cfg.resources.for_kind(kind).exclude_patterns,
96 kind.as_str(),
97 )?)
98}
99
100fn open_resource_dir(
104 root: &Path,
105 kind_label: &str,
106 issues: &mut Vec<ValidationIssue>,
107) -> anyhow::Result<Option<std::fs::ReadDir>> {
108 match try_read_resource_dir(root, kind_label) {
109 Ok(rd) => Ok(rd),
110 Err(Error::InvalidFormat { path, message }) => {
111 issues.push(ValidationIssue { path, message });
112 Ok(None)
113 }
114 Err(e) => Err(e.into()),
115 }
116}
117
118fn compile_name_pattern(
123 raw: Option<&str>,
124 config_key: &str,
125) -> anyhow::Result<Option<(String, Regex)>> {
126 match raw {
127 Some(p) => Ok(Some((
128 p.to_string(),
129 Regex::new(p).map_err(|e| anyhow!("invalid {config_key} regex {p:?}: {e}"))?,
130 ))),
131 None => Ok(None),
132 }
133}
134
135fn check_name_pattern(
139 pattern: Option<&(String, Regex)>,
140 name: &str,
141 path: &Path,
142 kind_label: &str,
143 config_key: &str,
144 issues: &mut Vec<ValidationIssue>,
145) {
146 let Some((pattern_str, re)) = pattern else {
147 return;
148 };
149 if !re.is_match(name) {
150 issues.push(ValidationIssue {
151 path: path.to_path_buf(),
152 message: format!(
153 "{kind_label} name '{name}' does not match {config_key} '{pattern_str}'"
154 ),
155 });
156 }
157}
158
159fn validate_catalog_schemas(
160 catalogs_root: &Path,
161 name_pattern: Option<&str>,
162 excludes: &[Regex],
163 issues: &mut Vec<ValidationIssue>,
164) -> anyhow::Result<()> {
165 let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
166 return Ok(());
167 };
168
169 let pattern = compile_name_pattern(name_pattern, "catalog_name_pattern")?;
170
171 for entry in read_dir {
172 let entry = entry?;
173 if !entry.file_type()?.is_dir() {
174 tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
175 continue;
176 }
177 let dir = entry.path();
178 let schema_path = dir.join("schema.yaml");
179 if !schema_path.is_file() {
180 continue;
181 }
182
183 let cat = match catalog_io::read_schema_file(&schema_path) {
184 Ok(c) => c,
185 Err(e) => {
186 issues.push(ValidationIssue {
187 path: schema_path.clone(),
188 message: format!("parse error: {e}"),
189 });
190 continue;
191 }
192 };
193
194 if crate::config::is_excluded(&cat.name, excludes) {
199 continue;
200 }
201
202 let dir_name = entry.file_name().to_string_lossy().into_owned();
206 if cat.name != dir_name {
207 issues.push(ValidationIssue {
208 path: schema_path.clone(),
209 message: format!(
210 "catalog name '{}' does not match its directory '{}'",
211 cat.name, dir_name
212 ),
213 });
214 }
215
216 check_name_pattern(
217 pattern.as_ref(),
218 &cat.name,
219 &schema_path,
220 "catalog",
221 "catalog_name_pattern",
222 issues,
223 );
224 }
225
226 Ok(())
227}
228
229fn validate_content_blocks(
230 content_blocks_root: &Path,
231 name_pattern: Option<&str>,
232 excludes: &[Regex],
233 issues: &mut Vec<ValidationIssue>,
234) -> anyhow::Result<()> {
235 let Some(read_dir) = open_resource_dir(content_blocks_root, "content_blocks", issues)? else {
236 return Ok(());
237 };
238
239 let pattern = compile_name_pattern(name_pattern, "content_block_name_pattern")?;
240
241 for entry in read_dir {
242 let entry = entry?;
243 let path = entry.path();
244 if !entry.file_type()?.is_file() {
245 tracing::debug!(path = %path.display(), "skipping non-file entry");
246 continue;
247 }
248 if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
249 continue;
250 }
251 let stem = path
252 .file_stem()
253 .and_then(|s| s.to_str())
254 .unwrap_or_default()
255 .to_string();
256
257 let cb = match content_block_io::read_content_block_file(&path) {
258 Ok(cb) => cb,
259 Err(e) => {
260 issues.push(ValidationIssue {
261 path: path.clone(),
262 message: format!("parse error: {e}"),
263 });
264 continue;
265 }
266 };
267
268 if crate::config::is_excluded(&cb.name, excludes) {
269 continue;
270 }
271
272 if cb.name != stem {
273 issues.push(ValidationIssue {
274 path: path.clone(),
275 message: format!(
276 "content block name '{}' does not match its file stem '{}'",
277 cb.name, stem
278 ),
279 });
280 }
281
282 check_name_pattern(
283 pattern.as_ref(),
284 &cb.name,
285 &path,
286 "content block",
287 "content_block_name_pattern",
288 issues,
289 );
290 }
291
292 Ok(())
293}
294
295fn validate_email_templates(
296 email_templates_root: &Path,
297 excludes: &[Regex],
298 issues: &mut Vec<ValidationIssue>,
299) -> anyhow::Result<()> {
300 let Some(read_dir) = open_resource_dir(email_templates_root, "email_templates", issues)? else {
301 return Ok(());
302 };
303
304 for entry in read_dir {
305 let entry = entry?;
306 let path = entry.path();
307 if !entry.file_type()?.is_dir() {
308 tracing::debug!(path = %path.display(), "skipping non-directory entry");
309 continue;
310 }
311 let template_yaml_path = path.join("template.yaml");
312 if !template_yaml_path.is_file() {
313 continue;
314 }
315 let dir_name = entry.file_name().to_string_lossy().into_owned();
316
317 let et = match email_template_io::read_email_template_dir(&path) {
318 Ok(et) => et,
319 Err(e) => {
320 issues.push(ValidationIssue {
321 path: template_yaml_path.clone(),
322 message: format!("parse error: {e}"),
323 });
324 continue;
325 }
326 };
327
328 if crate::config::is_excluded(&et.name, excludes) {
329 continue;
330 }
331
332 if et.name != dir_name {
333 issues.push(ValidationIssue {
334 path: template_yaml_path.clone(),
335 message: format!(
336 "email template name '{}' does not match its directory '{}'",
337 et.name, dir_name
338 ),
339 });
340 }
341
342 if et.subject.is_empty() {
343 issues.push(ValidationIssue {
344 path: template_yaml_path.clone(),
345 message: format!("email template '{}' has an empty subject", et.name),
346 });
347 }
348 }
349
350 Ok(())
351}
352
353fn validate_custom_attributes(
354 registry_path: &Path,
355 name_pattern: Option<&str>,
356 excludes: &[Regex],
357 issues: &mut Vec<ValidationIssue>,
358) -> anyhow::Result<()> {
359 let registry = match custom_attribute_io::load_registry(registry_path) {
360 Ok(Some(r)) => r,
361 Ok(None) => return Ok(()),
362 Err(Error::YamlParse { path, source }) => {
363 issues.push(ValidationIssue {
364 path,
365 message: format!("parse error: {source}"),
366 });
367 return Ok(());
368 }
369 Err(e) => return Err(e.into()),
370 };
371
372 let pattern = compile_name_pattern(name_pattern, "custom_attribute_name_pattern")?;
373
374 let mut seen = HashSet::with_capacity(registry.attributes.len());
375 for attr in ®istry.attributes {
376 if crate::config::is_excluded(&attr.name, excludes) {
377 continue;
378 }
379 if !seen.insert(attr.name.as_str()) {
380 issues.push(ValidationIssue {
381 path: registry_path.to_path_buf(),
382 message: format!("duplicate custom attribute name '{}'", attr.name),
383 });
384 }
385
386 check_name_pattern(
387 pattern.as_ref(),
388 &attr.name,
389 registry_path,
390 "custom attribute",
391 "custom_attribute_name_pattern",
392 issues,
393 );
394 }
395
396 Ok(())
397}