Skip to main content

weft_core/package/
validate.rs

1//! Manifest validation (B1).
2//!
3//! `load_manifest` collapses the multiple `package.toml` dialects
4//! (`[package_info]` vs `[identity]+[package]`) into a single
5//! [`PackageManifest`], but it is *lenient*: a manifest with an empty name or a
6//! `kind`/`runtime` mismatch still loads. This module turns that implicit
7//! tolerance into explicit, severity-rated findings so the runtime can warn at
8//! startup and a future `weft package lint` command can surface them.
9//!
10//! Validation is pure (no I/O); callers that want to check the on-disk entry
11//! file pass `entry_exists` explicitly.
12
13use crate::package::config::PackageManifest;
14use std::fmt;
15
16/// Severity of a validation finding.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Severity {
19    /// Manifest is malformed in a way that breaks discovery or capability wiring.
20    Error,
21    /// Manifest loads but is inconsistent or under-specified.
22    Warning,
23}
24
25impl fmt::Display for Severity {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            Severity::Error => write!(f, "error"),
29            Severity::Warning => write!(f, "warning"),
30        }
31    }
32}
33
34/// A single validation finding for one manifest.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ManifestIssue {
37    pub severity: Severity,
38    /// Stable machine-readable code, e.g. `"missing-name"`.
39    pub code: String,
40    /// Human-readable explanation.
41    pub message: String,
42}
43
44impl ManifestIssue {
45    fn error(code: &str, message: impl Into<String>) -> Self {
46        Self { severity: Severity::Error, code: code.into(), message: message.into() }
47    }
48    fn warning(code: &str, message: impl Into<String>) -> Self {
49        Self { severity: Severity::Warning, code: code.into(), message: message.into() }
50    }
51}
52
53/// Validates a parsed manifest. `entry_exists` is the caller's check of whether
54/// the resolved entry file is present on disk (pass `None` to skip that check,
55/// e.g. when validating in-memory without a package dir).
56pub fn validate_manifest(
57    manifest: &PackageManifest,
58    entry_exists: Option<bool>,
59) -> Vec<ManifestIssue> {
60    let mut issues = Vec::new();
61
62    // --- identity ---
63    if manifest.package_info.name.trim().is_empty() {
64        issues.push(ManifestIssue::error(
65            "missing-name",
66            "package has no name; declare [package_info].name or [identity].name",
67        ));
68    }
69    if manifest.package_info.version.trim().is_empty() {
70        issues.push(ManifestIssue::warning(
71            "missing-version",
72            "package has no version; declare [package_info].version or [identity].version",
73        ));
74    }
75
76    // --- entry / runtime consistency ---
77    // Product packages compose capabilities via [requires]/[bindings] and have
78    // no executable entry of their own, so entry/runtime checks do not apply.
79    if !manifest.is_product() {
80        let runtime = manifest.runtime_kind();
81        match manifest.resolved_entry() {
82            None => issues.push(ManifestIssue::error(
83                "missing-entry",
84                "no entry resolved; declare [package_info].entry or [package].entry",
85            )),
86            Some(entry) => {
87                // kind <-> entry mismatch: wasm runtime must point at a .wasm file.
88                let entry_is_wasm = entry.ends_with(".wasm");
89                if runtime == "wasm" && !entry_is_wasm {
90                    issues.push(ManifestIssue::warning(
91                        "runtime-entry-mismatch",
92                        format!(
93                            "runtime=wasm but entry '{entry}' is not a .wasm file; \
94                             set runtime=service or fix the entry"
95                        ),
96                    ));
97                }
98                if runtime == "service" && entry_is_wasm {
99                    issues.push(ManifestIssue::warning(
100                        "runtime-entry-mismatch",
101                        format!(
102                            "runtime=service but entry '{entry}' is a .wasm file; \
103                             set runtime=wasm or fix the entry"
104                        ),
105                    ));
106                }
107                if let Some(false) = entry_exists {
108                    issues.push(ManifestIssue::error(
109                        "entry-not-found",
110                        format!("entry file '{entry}' does not exist on disk"),
111                    ));
112                }
113            }
114        }
115    }
116
117    // --- capability ---
118    // Product packages declare [requires], not [provides]; empty provides is expected.
119    if !manifest.is_product() && manifest.resolved_provides().is_empty() {
120        issues.push(ManifestIssue::warning(
121            "no-provides",
122            "package declares no capabilities (provides is empty); \
123             it cannot satisfy any binding",
124        ));
125    }
126
127    issues
128}
129
130/// Convenience: true if any finding is an error.
131pub fn has_errors(issues: &[ManifestIssue]) -> bool {
132    issues.iter().any(|i| i.severity == Severity::Error)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::package::config::load_manifest;
139    use std::io::Write;
140
141    fn manifest_from(test_name: &str, toml: &str) -> PackageManifest {
142        // Unique per-test dir avoids collisions under parallel test execution.
143        let dir = std::env::temp_dir().join(format!("weft-validate-test-{test_name}"));
144        let _ = std::fs::remove_dir_all(&dir);
145        let _ = std::fs::create_dir_all(&dir);
146        let mut f = std::fs::File::create(dir.join("package.toml")).unwrap();
147        f.write_all(toml.as_bytes()).unwrap();
148        load_manifest(&dir).unwrap()
149    }
150
151    #[test]
152    fn clean_wasm_manifest_has_no_issues() {
153        let m = manifest_from(
154            "clean-wasm",
155            r#"
156[package_info]
157name = "agent-runtime"
158version = "0.1.0"
159description = "x"
160entry = "package.wasm"
161provides = ["agent.runtime"]
162"#,
163        );
164        let issues = validate_manifest(&m, Some(true));
165        assert!(!has_errors(&issues), "unexpected issues: {issues:?}");
166    }
167
168    #[test]
169    fn empty_name_is_error() {
170        // Mimics the orphan installed/memory dialect that resolved to an empty name.
171        let m = manifest_from(
172            "empty-name",
173            r#"
174[package]
175runtime = "wasm"
176entry = "package.wasm"
177"#,
178        );
179        let issues = validate_manifest(&m, Some(true));
180        assert!(has_errors(&issues));
181        assert!(issues.iter().any(|i| i.code == "missing-name"));
182    }
183
184    #[test]
185    fn wasm_runtime_with_non_wasm_entry_warns() {
186        // Mimics the memory-store kind=wasm but service entry bug B2 fixed.
187        let m = manifest_from(
188            "wasm-nonwasm-entry",
189            r#"
190[package_info]
191name = "memory-store"
192version = "0.1.0"
193description = "x"
194entry = "start-memory-runtime.ps1"
195provides = ["memory.store"]
196
197[package]
198runtime = "wasm"
199"#,
200        );
201        let issues = validate_manifest(&m, Some(true));
202        assert!(issues.iter().any(|i| i.code == "runtime-entry-mismatch"));
203    }
204
205    #[test]
206    fn missing_entry_file_is_error() {
207        let m = manifest_from(
208            "stub-missing-entry",
209            r#"
210[package_info]
211name = "stub"
212version = "0.1.0"
213description = "x"
214entry = "package.wasm"
215provides = ["x.y"]
216"#,
217        );
218        let issues = validate_manifest(&m, Some(false));
219        assert!(issues.iter().any(|i| i.code == "entry-not-found"));
220    }
221
222    #[test]
223    fn product_package_skips_entry_and_provides_checks() {
224        // Mimics packages/weft-code: kind=product, no entry, only [requires].
225        // Must NOT report entry-not-found / missing-entry / no-provides.
226        let m = manifest_from(
227            "product-pkg",
228            r#"
229schema_version = 2
230
231[identity]
232name = "weft-code"
233version = "0.1.0"
234description = "product"
235
236[package]
237kind = "product"
238
239[requires]
240capabilities = ["agent.runtime", "weft_code.runtime"]
241"#,
242        );
243        assert!(m.is_product(), "kind=product should be detected");
244        // entry_exists=false would normally trigger entry-not-found for a wasm pkg
245        let issues = validate_manifest(&m, Some(false));
246        assert!(
247            !issues.iter().any(|i| matches!(
248                i.code.as_str(),
249                "entry-not-found" | "missing-entry" | "runtime-entry-mismatch" | "no-provides"
250            )),
251            "product package should skip entry/provides checks, got: {issues:?}"
252        );
253    }
254}