Skip to main content

veryl_metadata/
component.rs

1use crate::ComponentManifest;
2use crate::component_manifest::{COMMITTED_MANIFEST_FILE, parse_library_manifest};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7
8/// A cargo package providing user-defined verification components,
9/// declared as a `[[components]]` entry. Every name the package exports
10/// with `veryl_component_export!` becomes available as `$comp::<name>`
11/// in `#[test]` modules.
12#[derive(Clone, Debug, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct Component {
15    /// Path to the component's cargo package, relative to the directory
16    /// containing Veryl.toml.
17    pub path: PathBuf,
18    /// Optional committed prebuilt wasm binary.
19    #[serde(default)]
20    pub wasm: Option<PathBuf>,
21}
22
23impl Component {
24    /// Enumerates the export names and interface manifests this package
25    /// provides. A single source is used wholly — the newer of the build
26    /// sidecar under `target_dir` and the committed `veryl.manifest.json`
27    /// (by file mtime), then the prebuilt wasm's manifest section — so
28    /// exports removed from the sources do not linger from a staler file.
29    /// Non-identifier names are dropped with a warning (see
30    /// [`veryl_component_sys::is_valid_component_name`]).
31    pub fn collect_manifests(
32        &self,
33        root: &Path,
34        target_dir: &Path,
35    ) -> Vec<(String, ComponentManifest)> {
36        let crate_dir = root.join(&self.path);
37        let sidecar =
38            component_crate_name(&crate_dir).map(|name| sidecar_manifest_path(target_dir, &name));
39        let committed = crate_dir.join(COMMITTED_MANIFEST_FILE);
40        let found =
41            read_newest_manifest_file(&[sidecar.as_deref(), Some(&committed)]).or_else(|| {
42                let wasm = std::fs::read(root.join(self.wasm.as_ref()?)).ok()?;
43                ComponentManifest::parse_all_from_wasm(&wasm)
44            });
45        let mut ret: Vec<_> = found
46            .unwrap_or_default()
47            .into_iter()
48            .filter(|(name, _)| {
49                let valid = veryl_component_sys::is_valid_component_name(name);
50                if !valid {
51                    log::warn!(
52                        "component export `{name}` in {} is not an identifier and cannot be referenced as $comp::<name>; ignored",
53                        self.path.display()
54                    );
55                }
56                valid
57            })
58            .collect();
59        ret.sort_by(|a, b| a.0.cmp(&b.0));
60        ret
61    }
62}
63
64/// The `[package].name` of the cargo package at `crate_dir`.
65pub fn component_crate_name(crate_dir: &Path) -> Option<String> {
66    let text = std::fs::read_to_string(crate_dir.join("Cargo.toml")).ok()?;
67    let value: toml::Value = toml::from_str(&text).ok()?;
68    Some(value.get("package")?.get("name")?.as_str()?.to_string())
69}
70
71/// Path of the build-output manifest sidecar for a component crate. The
72/// name derives from the cargo package name — not the built artifact — so
73/// the writer (`veryl test`) and this reader agree regardless of platform
74/// library prefixes or a `[lib] name` override.
75pub fn sidecar_manifest_path(target_dir: &Path, crate_name: &str) -> PathBuf {
76    let snake = crate_name.replace('-', "_");
77    target_dir
78        .join("release")
79        .join(format!("{snake}.manifest.json"))
80}
81
82/// Reads every export from the committed `veryl.manifest.json` in the
83/// component crate.
84pub fn read_committed_manifests(crate_dir: &Path) -> Option<HashMap<String, ComponentManifest>> {
85    read_manifest_file(&crate_dir.join(COMMITTED_MANIFEST_FILE))
86}
87
88/// Reads an aggregated manifest file; an absent, unparsable or empty one
89/// counts as no source at all so a fallback can take over.
90fn read_manifest_file(path: &Path) -> Option<HashMap<String, ComponentManifest>> {
91    let json = std::fs::read_to_string(path).ok()?;
92    let manifests = parse_library_manifest(&json);
93    (!manifests.is_empty()).then_some(manifests)
94}
95
96/// Reads the most recently modified of the candidate manifest files that
97/// parses to a non-empty type map. Recency decides between a build
98/// sidecar and a committed manifest: a fresh checkout makes the committed
99/// file newer than a leftover sidecar, and a local build the reverse.
100fn read_newest_manifest_file(
101    candidates: &[Option<&Path>],
102) -> Option<HashMap<String, ComponentManifest>> {
103    let mut found: Vec<(SystemTime, &Path)> = candidates
104        .iter()
105        .flatten()
106        .filter_map(|path| {
107            let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
108            Some((mtime, *path))
109        })
110        .collect();
111    found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
112    found
113        .into_iter()
114        .find_map(|(_, path)| read_manifest_file(path))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn manifest_json(ty: &str) -> String {
122        format!(r#"{{"types":{{"{ty}":{{"kind":"clocked"}}}}}}"#)
123    }
124
125    fn setup(dir: &Path) -> Component {
126        std::fs::create_dir_all(dir.join("crate")).unwrap();
127        std::fs::create_dir_all(dir.join("target/release")).unwrap();
128        std::fs::write(
129            dir.join("crate/Cargo.toml"),
130            "[package]\nname = \"demo-comp\"\n",
131        )
132        .unwrap();
133        Component {
134            path: "crate".into(),
135            wasm: None,
136        }
137    }
138
139    fn names(entry: &Component, root: &Path) -> Vec<String> {
140        entry
141            .collect_manifests(root, &root.join("target"))
142            .into_iter()
143            .map(|(n, _)| n)
144            .collect()
145    }
146
147    #[test]
148    fn newest_manifest_source_wins() {
149        let dir = std::env::temp_dir().join(format!("veryl_newest_{}", std::process::id()));
150        let _ = std::fs::remove_dir_all(&dir);
151        let entry = setup(&dir);
152        let committed = dir.join("crate").join(COMMITTED_MANIFEST_FILE);
153        let sidecar = sidecar_manifest_path(&dir.join("target"), "demo-comp");
154
155        std::fs::write(&committed, manifest_json("from_committed")).unwrap();
156        assert_eq!(names(&entry, &dir), ["from_committed"]);
157
158        // A later build sidecar shadows the committed manifest...
159        std::thread::sleep(std::time::Duration::from_millis(20));
160        std::fs::write(&sidecar, manifest_json("from_sidecar")).unwrap();
161        assert_eq!(names(&entry, &dir), ["from_sidecar"]);
162
163        // ...until the committed manifest is refreshed (e.g. a checkout).
164        std::thread::sleep(std::time::Duration::from_millis(20));
165        std::fs::write(&committed, manifest_json("from_committed")).unwrap();
166        assert_eq!(names(&entry, &dir), ["from_committed"]);
167
168        let _ = std::fs::remove_dir_all(&dir);
169    }
170
171    #[test]
172    fn non_identifier_export_names_are_dropped() {
173        let dir = std::env::temp_dir().join(format!("veryl_names_{}", std::process::id()));
174        let _ = std::fs::remove_dir_all(&dir);
175        let entry = setup(&dir);
176        std::fs::write(
177            dir.join("crate").join(COMMITTED_MANIFEST_FILE),
178            r#"{"types":{"ok_name":{"kind":"clocked"},"bus::monitor":{"kind":"clocked"},"1bad":{}}}"#,
179        )
180        .unwrap();
181        assert_eq!(names(&entry, &dir), ["ok_name"]);
182        let _ = std::fs::remove_dir_all(&dir);
183    }
184}