Skip to main content

bphelper_cli/validate/
mod.rs

1//! Battery pack validation: structure checks and template compilation.
2
3use anyhow::{Context, Result, bail};
4use std::path::Path;
5
6// ============================================================================
7// Validate command
8// ============================================================================
9
10// [impl cli.validate.purpose]
11// [impl cli.validate.default-path]
12pub(crate) fn validate_battery_pack_cmd(path: Option<&str>) -> Result<()> {
13    let crate_root = match path {
14        Some(p) => std::path::PathBuf::from(p),
15        None => std::env::current_dir().context("failed to get current directory")?,
16    };
17
18    let cargo_toml = crate_root.join("Cargo.toml");
19    let content = std::fs::read_to_string(&cargo_toml)
20        .with_context(|| format!("failed to read {}", cargo_toml.display()))?;
21
22    // Check for virtual/workspace manifest before attempting battery pack parse
23    let raw: toml::Value = toml::from_str(&content)
24        .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
25    if raw.get("package").is_none() {
26        if raw.get("workspace").is_some() {
27            // [impl cli.validate.workspace-error]
28            bail!(
29                "{} is a workspace manifest, not a battery pack crate.\n\
30                 Run this from a battery pack crate directory, or use --path to point to one.",
31                cargo_toml.display()
32            );
33        } else {
34            // [impl cli.validate.no-package]
35            bail!(
36                "{} has no [package] section — is this a battery pack crate?",
37                cargo_toml.display()
38            );
39        }
40    }
41
42    let spec = bphelper_manifest::parse_battery_pack(&content)
43        .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
44
45    // [impl cli.validate.checks]
46    let mut report = spec.validate_spec();
47    report.merge(bphelper_manifest::validate_on_disk(&spec, &crate_root));
48
49    // [impl cli.validate.clean]
50    if report.is_clean() {
51        validate_templates(crate_root.to_str().unwrap_or("."))?;
52        println!("{} is valid", spec.name);
53        return Ok(());
54    }
55
56    // [impl cli.validate.severity]
57    // [impl cli.validate.rule-id]
58    let mut errors = 0;
59    let mut warnings = 0;
60    for diag in &report.diagnostics {
61        match diag.severity {
62            bphelper_manifest::Severity::Error => {
63                eprintln!("error[{}]: {}", diag.rule, diag.message);
64                errors += 1;
65            }
66            bphelper_manifest::Severity::Warning => {
67                eprintln!("warning[{}]: {}", diag.rule, diag.message);
68                warnings += 1;
69            }
70        }
71    }
72
73    // [impl cli.validate.errors]
74    if errors > 0 {
75        bail!(
76            "validation failed: {} error(s), {} warning(s)",
77            errors,
78            warnings
79        );
80    }
81
82    // [impl cli.validate.warnings-only]
83    // Warnings only — still succeeds
84    validate_templates(crate_root.to_str().unwrap_or("."))?;
85    println!("{} is valid ({} warning(s))", spec.name, warnings);
86    Ok(())
87}
88
89/// Validate that each template in a battery pack generates a project that compiles
90/// and passes tests.
91///
92/// For each template declared in the battery pack's metadata:
93/// 1. Generates a project into a temporary directory
94/// 2. Runs `cargo check` to verify it compiles
95/// 3. Runs `cargo test` to verify tests pass
96///
97/// Compiled artifacts are cached in `<target_dir>/bp-validate/` so that
98/// subsequent runs are faster.
99// [impl cli.validate.templates]
100// [impl cli.validate.templates.cache]
101pub fn validate_templates(manifest_dir: &str) -> Result<()> {
102    let manifest_dir = Path::new(manifest_dir);
103    let cargo_toml = manifest_dir.join("Cargo.toml");
104    let content = std::fs::read_to_string(&cargo_toml)
105        .with_context(|| format!("failed to read {}", cargo_toml.display()))?;
106
107    let crate_name = manifest_dir
108        .file_name()
109        .and_then(|s| s.to_str())
110        .unwrap_or("unknown");
111    let spec = bphelper_manifest::parse_battery_pack(&content)
112        .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", cargo_toml.display()))?;
113
114    if spec.templates.is_empty() {
115        // [impl cli.validate.templates.none]
116        println!("no templates to validate");
117        return Ok(());
118    }
119
120    // Stable target dir for caching compiled artifacts across runs.
121    let metadata = cargo_metadata::MetadataCommand::new()
122        .manifest_path(&cargo_toml)
123        .no_deps()
124        .exec()
125        .context("failed to run cargo metadata")?;
126    let shared_target_dir = metadata.target_directory.join("bp-validate");
127
128    for (name, template) in &spec.templates {
129        println!("validating template '{name}'...");
130
131        let tmp = tempfile::tempdir().context("failed to create temp directory")?;
132
133        let project_name = format!("bp-validate-{name}");
134
135        let opts = crate::template_engine::GenerateOpts {
136            render: crate::template_engine::RenderOpts {
137                crate_root: manifest_dir.to_path_buf(),
138                template_path: template.path.clone(),
139                project_name,
140                defines: std::collections::BTreeMap::new(),
141            },
142            destination: Some(tmp.path().to_path_buf()),
143            git_init: false,
144        };
145
146        let project_dir = crate::template_engine::generate(opts)
147            .with_context(|| format!("failed to generate template '{name}'"))?;
148
149        write_crates_io_patches(&project_dir, &metadata)?;
150
151        // cargo check
152        let output = std::process::Command::new("cargo")
153            .args(["check"])
154            .env("CARGO_TARGET_DIR", &*shared_target_dir)
155            .current_dir(&project_dir)
156            .output()
157            .context("failed to run cargo check")?;
158        anyhow::ensure!(
159            output.status.success(),
160            "cargo check failed for template '{name}':\n{}",
161            String::from_utf8_lossy(&output.stderr)
162        );
163
164        // cargo test
165        let output = std::process::Command::new("cargo")
166            .args(["test"])
167            .env("CARGO_TARGET_DIR", &*shared_target_dir)
168            .current_dir(&project_dir)
169            .output()
170            .context("failed to run cargo test")?;
171        anyhow::ensure!(
172            output.status.success(),
173            "cargo test failed for template '{name}':\n{}",
174            String::from_utf8_lossy(&output.stderr)
175        );
176
177        println!("template '{name}' ok");
178    }
179
180    println!(
181        "all {} template(s) for '{}' validated successfully",
182        spec.templates.len(),
183        crate_name
184    );
185    Ok(())
186}
187
188/// Write a `.cargo/config.toml` that patches crates-io dependencies with local
189/// workspace packages, so template validation builds against current source.
190// [impl cli.validate.templates.patch]
191fn write_crates_io_patches(project_dir: &Path, metadata: &cargo_metadata::Metadata) -> Result<()> {
192    let mut patches = String::from("[patch.crates-io]\n");
193    for pkg in &metadata.workspace_packages() {
194        let path = pkg.manifest_path.parent().unwrap();
195        patches.push_str(&format!("{} = {{ path = \"{}\" }}\n", pkg.name, path));
196    }
197
198    // Forward any existing patches from the battery pack's .cargo/config.toml
199    // so transitive dependencies (e.g. battery-pack with feature flags) resolve
200    // against local source when running in a patched development environment.
201    let parent_config = metadata.workspace_root.join(".cargo/config.toml");
202    if let Ok(content) = std::fs::read_to_string(&parent_config)
203        && let Ok(parsed) = content.parse::<toml::Table>()
204        && let Some(toml::Value::Table(patch_section)) = parsed.get("patch")
205        && let Some(toml::Value::Table(crates_io)) = patch_section.get("crates-io")
206    {
207        for (name, value) in crates_io {
208            // Skip packages already covered by workspace members
209            if metadata
210                .workspace_packages()
211                .iter()
212                .any(|p| p.name == *name)
213            {
214                continue;
215            }
216            patches.push_str(&format!("{name} = {value}\n"));
217        }
218    }
219
220    let cargo_dir = project_dir.join(".cargo");
221    std::fs::create_dir_all(&cargo_dir)
222        .with_context(|| format!("failed to create {}", cargo_dir.display()))?;
223    std::fs::write(cargo_dir.join("config.toml"), patches)
224        .context("failed to write .cargo/config.toml")?;
225    Ok(())
226}
227
228#[cfg(test)]
229mod tests;