use crate::config::ConfigFile;
use crate::error::Error;
use crate::fs::{
catalog_io, content_block_io, custom_attribute_io, email_template_io, try_read_resource_dir,
};
use crate::resource::ResourceKind;
use anyhow::anyhow;
use clap::Args;
use regex_lite::Regex;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use super::selected_kinds;
#[derive(Args, Debug)]
pub struct ValidateArgs {
#[arg(long, value_enum)]
pub resource: Option<ResourceKind>,
}
#[derive(Debug)]
struct ValidationIssue {
path: PathBuf,
message: String,
}
pub async fn run(args: &ValidateArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
let kinds = selected_kinds(args.resource, &cfg.resources);
let mut issues: Vec<ValidationIssue> = Vec::new();
for kind in kinds {
match kind {
ResourceKind::CatalogSchema => {
let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
let excludes = compile_kind_excludes(cfg, kind)?;
validate_catalog_schemas(
&catalogs_root,
cfg.naming.catalog_name_pattern.as_deref(),
&excludes,
&mut issues,
)?;
}
ResourceKind::ContentBlock => {
let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
let excludes = compile_kind_excludes(cfg, kind)?;
validate_content_blocks(
&content_blocks_root,
cfg.naming.content_block_name_pattern.as_deref(),
&excludes,
&mut issues,
)?;
}
ResourceKind::EmailTemplate => {
let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
let excludes = compile_kind_excludes(cfg, kind)?;
validate_email_templates(&email_templates_root, &excludes, &mut issues)?;
}
ResourceKind::CustomAttribute => {
let registry_path = config_dir.join(&cfg.resources.custom_attribute.path);
let excludes = compile_kind_excludes(cfg, kind)?;
validate_custom_attributes(
®istry_path,
cfg.naming.custom_attribute_name_pattern.as_deref(),
&excludes,
&mut issues,
)?;
}
}
}
if issues.is_empty() {
eprintln!("✓ All checks passed.");
return Ok(());
}
eprintln!("✗ Validation found {} issue(s):", issues.len());
for issue in &issues {
eprintln!(" • {}: {}", issue.path.display(), issue.message);
}
Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
}
fn compile_kind_excludes(cfg: &ConfigFile, kind: ResourceKind) -> anyhow::Result<Vec<Regex>> {
Ok(crate::config::compile_exclude_patterns(
&cfg.resources.for_kind(kind).exclude_patterns,
kind.as_str(),
)?)
}
fn open_resource_dir(
root: &Path,
kind_label: &str,
issues: &mut Vec<ValidationIssue>,
) -> anyhow::Result<Option<std::fs::ReadDir>> {
match try_read_resource_dir(root, kind_label) {
Ok(rd) => Ok(rd),
Err(Error::InvalidFormat { path, message }) => {
issues.push(ValidationIssue { path, message });
Ok(None)
}
Err(e) => Err(e.into()),
}
}
fn compile_name_pattern(
raw: Option<&str>,
config_key: &str,
) -> anyhow::Result<Option<(String, Regex)>> {
match raw {
Some(p) => Ok(Some((
p.to_string(),
Regex::new(p).map_err(|e| anyhow!("invalid {config_key} regex {p:?}: {e}"))?,
))),
None => Ok(None),
}
}
fn check_name_pattern(
pattern: Option<&(String, Regex)>,
name: &str,
path: &Path,
kind_label: &str,
config_key: &str,
issues: &mut Vec<ValidationIssue>,
) {
let Some((pattern_str, re)) = pattern else {
return;
};
if !re.is_match(name) {
issues.push(ValidationIssue {
path: path.to_path_buf(),
message: format!(
"{kind_label} name '{name}' does not match {config_key} '{pattern_str}'"
),
});
}
}
fn validate_catalog_schemas(
catalogs_root: &Path,
name_pattern: Option<&str>,
excludes: &[Regex],
issues: &mut Vec<ValidationIssue>,
) -> anyhow::Result<()> {
let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
return Ok(());
};
let pattern = compile_name_pattern(name_pattern, "catalog_name_pattern")?;
for entry in read_dir {
let entry = entry?;
if !entry.file_type()?.is_dir() {
tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
continue;
}
let dir = entry.path();
let schema_path = dir.join("schema.yaml");
if !schema_path.is_file() {
continue;
}
let cat = match catalog_io::read_schema_file(&schema_path) {
Ok(c) => c,
Err(e) => {
issues.push(ValidationIssue {
path: schema_path.clone(),
message: format!("parse error: {e}"),
});
continue;
}
};
if crate::config::is_excluded(&cat.name, excludes) {
continue;
}
let dir_name = entry.file_name().to_string_lossy().into_owned();
if cat.name != dir_name {
issues.push(ValidationIssue {
path: schema_path.clone(),
message: format!(
"catalog name '{}' does not match its directory '{}'",
cat.name, dir_name
),
});
}
check_name_pattern(
pattern.as_ref(),
&cat.name,
&schema_path,
"catalog",
"catalog_name_pattern",
issues,
);
}
Ok(())
}
fn validate_content_blocks(
content_blocks_root: &Path,
name_pattern: Option<&str>,
excludes: &[Regex],
issues: &mut Vec<ValidationIssue>,
) -> anyhow::Result<()> {
let Some(read_dir) = open_resource_dir(content_blocks_root, "content_blocks", issues)? else {
return Ok(());
};
let pattern = compile_name_pattern(name_pattern, "content_block_name_pattern")?;
for entry in read_dir {
let entry = entry?;
let path = entry.path();
if !entry.file_type()?.is_file() {
tracing::debug!(path = %path.display(), "skipping non-file entry");
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let cb = match content_block_io::read_content_block_file(&path) {
Ok(cb) => cb,
Err(e) => {
issues.push(ValidationIssue {
path: path.clone(),
message: format!("parse error: {e}"),
});
continue;
}
};
if crate::config::is_excluded(&cb.name, excludes) {
continue;
}
if cb.name != stem {
issues.push(ValidationIssue {
path: path.clone(),
message: format!(
"content block name '{}' does not match its file stem '{}'",
cb.name, stem
),
});
}
check_name_pattern(
pattern.as_ref(),
&cb.name,
&path,
"content block",
"content_block_name_pattern",
issues,
);
}
Ok(())
}
fn validate_email_templates(
email_templates_root: &Path,
excludes: &[Regex],
issues: &mut Vec<ValidationIssue>,
) -> anyhow::Result<()> {
let Some(read_dir) = open_resource_dir(email_templates_root, "email_templates", issues)? else {
return Ok(());
};
for entry in read_dir {
let entry = entry?;
let path = entry.path();
if !entry.file_type()?.is_dir() {
tracing::debug!(path = %path.display(), "skipping non-directory entry");
continue;
}
let template_yaml_path = path.join("template.yaml");
if !template_yaml_path.is_file() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().into_owned();
let et = match email_template_io::read_email_template_dir(&path) {
Ok(et) => et,
Err(e) => {
issues.push(ValidationIssue {
path: template_yaml_path.clone(),
message: format!("parse error: {e}"),
});
continue;
}
};
if crate::config::is_excluded(&et.name, excludes) {
continue;
}
if et.name != dir_name {
issues.push(ValidationIssue {
path: template_yaml_path.clone(),
message: format!(
"email template name '{}' does not match its directory '{}'",
et.name, dir_name
),
});
}
if et.subject.is_empty() {
issues.push(ValidationIssue {
path: template_yaml_path.clone(),
message: format!("email template '{}' has an empty subject", et.name),
});
}
}
Ok(())
}
fn validate_custom_attributes(
registry_path: &Path,
name_pattern: Option<&str>,
excludes: &[Regex],
issues: &mut Vec<ValidationIssue>,
) -> anyhow::Result<()> {
let registry = match custom_attribute_io::load_registry(registry_path) {
Ok(Some(r)) => r,
Ok(None) => return Ok(()),
Err(Error::YamlParse { path, source }) => {
issues.push(ValidationIssue {
path,
message: format!("parse error: {source}"),
});
return Ok(());
}
Err(e) => return Err(e.into()),
};
let pattern = compile_name_pattern(name_pattern, "custom_attribute_name_pattern")?;
let mut seen = HashSet::with_capacity(registry.attributes.len());
for attr in ®istry.attributes {
if crate::config::is_excluded(&attr.name, excludes) {
continue;
}
if !seen.insert(attr.name.as_str()) {
issues.push(ValidationIssue {
path: registry_path.to_path_buf(),
message: format!("duplicate custom attribute name '{}'", attr.name),
});
}
check_name_pattern(
pattern.as_ref(),
&attr.name,
registry_path,
"custom attribute",
"custom_attribute_name_pattern",
issues,
);
}
Ok(())
}