Skip to main content

braze_sync/cli/
validate.rs

1//! `braze-sync validate` — local-only structural and naming checks.
2//!
3//! v0.1.0 supports Catalog Schema. Other resource kinds emit a "not yet
4//! implemented" warning.
5//!
6//! Validate is special among CLI commands: **it does not need a Braze
7//! API key**. The whole point is "I want a pre-merge check that runs in
8//! CI on a fork PR where the secret isn't available". `cli::run`
9//! dispatches Validate directly from the parsed `ConfigFile`, skipping
10//! the env-resolution stage that other commands go through.
11//!
12//! Issues are collected across the whole run and reported at the end,
13//! so the user sees every problem in one pass instead of fix-and-rerun
14//! cycles.
15
16use crate::config::ConfigFile;
17use crate::error::Error;
18use crate::fs::catalog_io;
19use crate::resource::ResourceKind;
20use anyhow::anyhow;
21use clap::Args;
22use regex_lite::Regex;
23use std::path::{Path, PathBuf};
24
25use super::{selected_kinds, warn_unimplemented};
26
27#[derive(Args, Debug)]
28pub struct ValidateArgs {
29    /// Limit validation to a specific resource kind.
30    #[arg(long, value_enum)]
31    pub resource: Option<ResourceKind>,
32}
33
34#[derive(Debug)]
35struct ValidationIssue {
36    path: PathBuf,
37    message: String,
38}
39
40pub async fn run(args: &ValidateArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
41    let kinds = selected_kinds(args.resource, &cfg.resources);
42
43    let mut issues: Vec<ValidationIssue> = Vec::new();
44
45    for kind in kinds {
46        match kind {
47            ResourceKind::CatalogSchema => {
48                let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
49                validate_catalog_schemas(
50                    &catalogs_root,
51                    cfg.naming.catalog_name_pattern.as_deref(),
52                    &mut issues,
53                )?;
54            }
55            other => warn_unimplemented(other),
56        }
57    }
58
59    if issues.is_empty() {
60        eprintln!("✓ All checks passed.");
61        return Ok(());
62    }
63
64    eprintln!("✗ Validation found {} issue(s):", issues.len());
65    for issue in &issues {
66        eprintln!("  • {}: {}", issue.path.display(), issue.message);
67    }
68
69    // Wrap in Error::Config so exit_code_for maps it to exit 3
70    // (config / argument error per §7.1) — semantically the user gave
71    // bad input.
72    Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
73}
74
75fn validate_catalog_schemas(
76    catalogs_root: &Path,
77    name_pattern: Option<&str>,
78    issues: &mut Vec<ValidationIssue>,
79) -> anyhow::Result<()> {
80    if !catalogs_root.exists() {
81        // Empty project — nothing to validate but nothing wrong either.
82        return Ok(());
83    }
84    if !catalogs_root.is_dir() {
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
92    // Compile the naming pattern once. A bad regex in the user's
93    // config is a hard failure, not a per-catalog issue, so propagate
94    // it via anyhow rather than pushing into `issues`.
95    let pattern: Option<(String, Regex)> = match name_pattern {
96        Some(p) => Some((
97            p.to_string(),
98            Regex::new(p).map_err(|e| anyhow!("invalid catalog_name_pattern regex {p:?}: {e}"))?,
99        )),
100        None => None,
101    };
102
103    for entry in std::fs::read_dir(catalogs_root)? {
104        let entry = entry?;
105        if !entry.file_type()?.is_dir() {
106            tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
107            continue;
108        }
109        let dir = entry.path();
110        let schema_path = dir.join("schema.yaml");
111        if !schema_path.is_file() {
112            // Catalog dir without schema.yaml: silently skip, mirroring
113            // load_all_schemas. A future Phase B layout might have
114            // items.csv-only dirs during partial edits.
115            continue;
116        }
117
118        // Try to parse. On failure, record an issue and continue —
119        // we want to surface every bad file in a single validate run,
120        // not bail at the first one.
121        let cat = match catalog_io::read_schema_file(&schema_path) {
122            Ok(c) => c,
123            Err(e) => {
124                issues.push(ValidationIssue {
125                    path: schema_path.clone(),
126                    message: format!("parse error: {e}"),
127                });
128                continue;
129            }
130        };
131
132        // Directory name must match the schema's `name:` field.
133        // load_all_schemas treats this as a hard error; here we
134        // downgrade to a soft issue so multiple files can be checked
135        // in one run.
136        let dir_name = entry.file_name().to_string_lossy().into_owned();
137        if cat.name != dir_name {
138            issues.push(ValidationIssue {
139                path: schema_path.clone(),
140                message: format!(
141                    "catalog name '{}' does not match its directory '{}'",
142                    cat.name, dir_name
143                ),
144            });
145        }
146
147        // Naming pattern check (only if configured).
148        if let Some((pattern_str, re)) = &pattern {
149            if !re.is_match(&cat.name) {
150                issues.push(ValidationIssue {
151                    path: schema_path.clone(),
152                    message: format!(
153                        "catalog name '{}' does not match catalog_name_pattern '{}'",
154                        cat.name, pattern_str
155                    ),
156                });
157            }
158        }
159    }
160
161    Ok(())
162}