1use crate::config::ConfigFile;
9use crate::error::Error;
10use crate::fs::{catalog_io, content_block_io, email_template_io};
11use crate::resource::ResourceKind;
12use anyhow::anyhow;
13use clap::Args;
14use regex_lite::Regex;
15use std::path::{Path, PathBuf};
16
17use super::{selected_kinds, warn_unimplemented};
18
19#[derive(Args, Debug)]
20pub struct ValidateArgs {
21 #[arg(long, value_enum)]
23 pub resource: Option<ResourceKind>,
24}
25
26#[derive(Debug)]
27struct ValidationIssue {
28 path: PathBuf,
29 message: String,
30}
31
32pub async fn run(args: &ValidateArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
33 let kinds = selected_kinds(args.resource, &cfg.resources);
34
35 let mut issues: Vec<ValidationIssue> = Vec::new();
36
37 for kind in kinds {
38 match kind {
39 ResourceKind::CatalogSchema => {
40 let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
41 validate_catalog_schemas(
42 &catalogs_root,
43 cfg.naming.catalog_name_pattern.as_deref(),
44 &mut issues,
45 )?;
46 }
47 ResourceKind::ContentBlock => {
48 let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
49 validate_content_blocks(
50 &content_blocks_root,
51 cfg.naming.content_block_name_pattern.as_deref(),
52 &mut issues,
53 )?;
54 }
55 ResourceKind::EmailTemplate => {
56 let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
57 validate_email_templates(&email_templates_root, &mut issues)?;
58 }
59 other => warn_unimplemented(other),
60 }
61 }
62
63 if issues.is_empty() {
64 eprintln!("✓ All checks passed.");
65 return Ok(());
66 }
67
68 eprintln!("✗ Validation found {} issue(s):", issues.len());
69 for issue in &issues {
70 eprintln!(" • {}: {}", issue.path.display(), issue.message);
71 }
72
73 Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
74}
75
76fn validate_catalog_schemas(
77 catalogs_root: &Path,
78 name_pattern: Option<&str>,
79 issues: &mut Vec<ValidationIssue>,
80) -> anyhow::Result<()> {
81 let read_dir = match std::fs::read_dir(catalogs_root) {
82 Ok(rd) => rd,
83 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
84 Err(_) if catalogs_root.is_file() => {
85 issues.push(ValidationIssue {
86 path: catalogs_root.to_path_buf(),
87 message: "expected directory for catalogs root".into(),
88 });
89 return Ok(());
90 }
91 Err(e) => return Err(e.into()),
92 };
93
94 let pattern: Option<(String, Regex)> = match name_pattern {
95 Some(p) => Some((
96 p.to_string(),
97 Regex::new(p).map_err(|e| anyhow!("invalid catalog_name_pattern regex {p:?}: {e}"))?,
98 )),
99 None => None,
100 };
101
102 for entry in read_dir {
103 let entry = entry?;
104 if !entry.file_type()?.is_dir() {
105 tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
106 continue;
107 }
108 let dir = entry.path();
109 let schema_path = dir.join("schema.yaml");
110 if !schema_path.is_file() {
111 continue;
112 }
113
114 let cat = match catalog_io::read_schema_file(&schema_path) {
115 Ok(c) => c,
116 Err(e) => {
117 issues.push(ValidationIssue {
118 path: schema_path.clone(),
119 message: format!("parse error: {e}"),
120 });
121 continue;
122 }
123 };
124
125 let dir_name = entry.file_name().to_string_lossy().into_owned();
129 if cat.name != dir_name {
130 issues.push(ValidationIssue {
131 path: schema_path.clone(),
132 message: format!(
133 "catalog name '{}' does not match its directory '{}'",
134 cat.name, dir_name
135 ),
136 });
137 }
138
139 if let Some((pattern_str, re)) = &pattern {
140 if !re.is_match(&cat.name) {
141 issues.push(ValidationIssue {
142 path: schema_path.clone(),
143 message: format!(
144 "catalog name '{}' does not match catalog_name_pattern '{}'",
145 cat.name, pattern_str
146 ),
147 });
148 }
149 }
150 }
151
152 Ok(())
153}
154
155fn validate_content_blocks(
156 content_blocks_root: &Path,
157 name_pattern: Option<&str>,
158 issues: &mut Vec<ValidationIssue>,
159) -> anyhow::Result<()> {
160 let read_dir = match std::fs::read_dir(content_blocks_root) {
161 Ok(rd) => rd,
162 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
163 Err(_) if content_blocks_root.is_file() => {
164 issues.push(ValidationIssue {
165 path: content_blocks_root.to_path_buf(),
166 message: "expected directory for the content_blocks root".into(),
167 });
168 return Ok(());
169 }
170 Err(e) => return Err(e.into()),
171 };
172
173 let pattern: Option<(String, Regex)> = match name_pattern {
174 Some(p) => Some((
175 p.to_string(),
176 Regex::new(p)
177 .map_err(|e| anyhow!("invalid content_block_name_pattern regex {p:?}: {e}"))?,
178 )),
179 None => None,
180 };
181
182 for entry in read_dir {
183 let entry = entry?;
184 let path = entry.path();
185 if !entry.file_type()?.is_file() {
186 tracing::debug!(path = %path.display(), "skipping non-file entry");
187 continue;
188 }
189 if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
190 continue;
191 }
192 let stem = path
193 .file_stem()
194 .and_then(|s| s.to_str())
195 .unwrap_or_default()
196 .to_string();
197
198 let cb = match content_block_io::read_content_block_file(&path) {
199 Ok(cb) => cb,
200 Err(e) => {
201 issues.push(ValidationIssue {
202 path: path.clone(),
203 message: format!("parse error: {e}"),
204 });
205 continue;
206 }
207 };
208
209 if cb.name != stem {
210 issues.push(ValidationIssue {
211 path: path.clone(),
212 message: format!(
213 "content block name '{}' does not match its file stem '{}'",
214 cb.name, stem
215 ),
216 });
217 }
218
219 if let Some((pattern_str, re)) = &pattern {
220 if !re.is_match(&cb.name) {
221 issues.push(ValidationIssue {
222 path: path.clone(),
223 message: format!(
224 "content block name '{}' does not match content_block_name_pattern '{}'",
225 cb.name, pattern_str
226 ),
227 });
228 }
229 }
230 }
231
232 Ok(())
233}
234
235fn validate_email_templates(
236 email_templates_root: &Path,
237 issues: &mut Vec<ValidationIssue>,
238) -> anyhow::Result<()> {
239 let read_dir = match std::fs::read_dir(email_templates_root) {
240 Ok(rd) => rd,
241 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
242 Err(_) if email_templates_root.is_file() => {
243 issues.push(ValidationIssue {
244 path: email_templates_root.to_path_buf(),
245 message: "expected directory for the email_templates root".into(),
246 });
247 return Ok(());
248 }
249 Err(e) => return Err(e.into()),
250 };
251
252 for entry in read_dir {
253 let entry = entry?;
254 let path = entry.path();
255 if !entry.file_type()?.is_dir() {
256 tracing::debug!(path = %path.display(), "skipping non-directory entry");
257 continue;
258 }
259 let template_yaml_path = path.join("template.yaml");
260 if !template_yaml_path.is_file() {
261 continue;
262 }
263 let dir_name = entry.file_name().to_string_lossy().into_owned();
264
265 let et = match email_template_io::read_email_template_dir(&path) {
266 Ok(et) => et,
267 Err(e) => {
268 issues.push(ValidationIssue {
269 path: template_yaml_path.clone(),
270 message: format!("parse error: {e}"),
271 });
272 continue;
273 }
274 };
275
276 if et.name != dir_name {
277 issues.push(ValidationIssue {
278 path: template_yaml_path.clone(),
279 message: format!(
280 "email template name '{}' does not match its directory '{}'",
281 et.name, dir_name
282 ),
283 });
284 }
285
286 if et.subject.is_empty() {
287 issues.push(ValidationIssue {
288 path: template_yaml_path.clone(),
289 message: format!("email template '{}' has an empty subject", et.name),
290 });
291 }
292 }
293
294 Ok(())
295}