Skip to main content

miden_note_schema/
artifact.rs

1//! Miden package discovery for note schema macros.
2
3use std::{
4    env, fs,
5    path::{Path, PathBuf},
6};
7
8use miden_mast_package::Package;
9use midenc_frontend_wasm_metadata::package_cache;
10
11use crate::{Error, NoteStorageSchema, Result};
12
13/// A loaded package artifact and its note storage schema.
14pub struct NotePackageArtifact {
15    path: PathBuf,
16    schema: NoteStorageSchema,
17    tracked_inputs: Vec<PathBuf>,
18}
19
20impl NotePackageArtifact {
21    /// Returns the exact package path used to load the schema.
22    pub fn path(&self) -> &Path {
23        &self.path
24    }
25
26    /// Returns the schema loaded from the package.
27    pub const fn schema(&self) -> &NoteStorageSchema {
28        &self.schema
29    }
30
31    /// Returns every manifest the resolver read to derive the package file name.
32    ///
33    /// A caller registers these files as rebuild inputs next to the package itself. A rename of
34    /// the note package changes a manifest but leaves the old package file in place, so nothing
35    /// else tells the caller that the selection changed.
36    pub fn tracked_inputs(&self) -> &[PathBuf] {
37        &self.tracked_inputs
38    }
39}
40
41/// Resolves package artifacts for one note macro crate.
42pub struct NotePackageResolver<'a> {
43    macro_crate: &'a str,
44}
45
46impl<'a> NotePackageResolver<'a> {
47    /// Creates a resolver whose diagnostics name `macro_crate`.
48    pub const fn new(macro_crate: &'a str) -> Self {
49        Self { macro_crate }
50    }
51
52    /// Loads the package built by a Miden project.
53    pub fn from_project(&self, project: &str) -> Result<NotePackageArtifact> {
54        let project_dir = self.resolve_manifest_path(project)?;
55        if !project_dir.is_dir() {
56            return Err(Error::new(format!(
57                "{}: note project directory '{}' does not exist",
58                self.macro_crate,
59                project_dir.display()
60            )));
61        }
62        let identity = project_identity(&project_dir);
63        let stems = &identity.stems;
64        let cache_dir = package_cache_dir()
65            .map_err(|error| Error::new(format!("{}: {error}", self.macro_crate)))?;
66        let package_path = resolve_project_package(&project_dir, stems, cache_dir.as_deref())
67            .map_err(|error| Error::new(format!("{}: {error}", self.macro_crate)))?
68            .ok_or_else(|| {
69                Error::new(missing_project_package_message(
70                    self.macro_crate,
71                    &project_dir,
72                    cache_dir.as_deref(),
73                    stems,
74                ))
75            })?;
76        self.load_package(package_path, identity.manifests)
77    }
78
79    /// Loads one exact Miden package path.
80    pub fn from_package(&self, package: &str) -> Result<NotePackageArtifact> {
81        let package_path = self.resolve_manifest_path(package)?;
82        if !package_path.is_file() {
83            return Err(Error::new(format!(
84                "{}: Miden package '{}' does not exist",
85                self.macro_crate,
86                package_path.display()
87            )));
88        }
89        self.load_package(package_path, Vec::new())
90    }
91
92    /// Resolves one path relative to the consuming crate manifest.
93    fn resolve_manifest_path(&self, value: &str) -> Result<PathBuf> {
94        let path = PathBuf::from(value);
95        if path.is_absolute() {
96            return Ok(path);
97        }
98        let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
99            Error::new(format!(
100                "{}: CARGO_MANIFEST_DIR is not set during macro expansion",
101                self.macro_crate
102            ))
103        })?;
104        Ok(PathBuf::from(manifest_dir).join(path))
105    }
106
107    /// Loads the package and its unique schema section.
108    fn load_package(
109        &self,
110        path: PathBuf,
111        tracked_inputs: Vec<PathBuf>,
112    ) -> Result<NotePackageArtifact> {
113        let path = path.canonicalize().map_err(|error| {
114            Error::new(format!(
115                "{}: failed to resolve Miden package '{}': {error}",
116                self.macro_crate,
117                path.display()
118            ))
119        })?;
120        let package = Package::deserialize_from_file(&path).map_err(|error| {
121            Error::new(format!(
122                "{}: failed to read Miden package '{}': {error}",
123                self.macro_crate,
124                path.display()
125            ))
126        })?;
127        let schema = NoteStorageSchema::from_package(&package).map_err(|error| {
128            Error::new(format!(
129                "{}: failed to read note storage schema from '{}': {error}",
130                self.macro_crate,
131                path.display()
132            ))
133        })?;
134        Ok(NotePackageArtifact {
135            path,
136            schema,
137            tracked_inputs,
138        })
139    }
140}
141
142/// Resolves one project package by package identity and output-directory priority.
143///
144/// A set package-cache directory replaces the search: the build owns that directory, so the
145/// output directories of the project are not consulted.
146fn resolve_project_package(
147    project_dir: &Path,
148    stems: &[String],
149    cache_dir: Option<&Path>,
150) -> Result<Option<PathBuf>> {
151    if let Some(cache_dir) = cache_dir {
152        return Ok(find_project_package_in_cache(cache_dir, stems));
153    }
154
155    let profiles = candidate_profiles();
156    for output_dir in project_output_dirs(project_dir, &profiles) {
157        if let Some(package) = find_project_package_in_dir(&output_dir, stems)? {
158            return Ok(Some(package));
159        }
160    }
161    Ok(None)
162}
163
164/// Returns the configured package cache directory, treating an empty value as unset.
165fn package_cache_dir() -> Result<Option<PathBuf>> {
166    env::var_os(package_cache::PACKAGE_CACHE_ENV)
167        .filter(|value| !value.is_empty())
168        .map(|value| absolutize(PathBuf::from(value)))
169        .transpose()
170}
171
172/// Returns Cargo and Miden profile names in lookup order.
173fn candidate_profiles() -> Vec<String> {
174    let mut profiles = Vec::new();
175    if let Ok(profile) = env::var("PROFILE") {
176        push_profile(&mut profiles, profile);
177    }
178    push_profile(&mut profiles, "release".to_owned());
179    push_profile(&mut profiles, "debug".to_owned());
180    push_profile(&mut profiles, "dev".to_owned());
181    profiles
182}
183
184/// Returns candidate output directories for one project.
185fn project_output_dirs(project_dir: &Path, profiles: &[String]) -> Vec<PathBuf> {
186    let mut dirs = Vec::new();
187
188    // Keep this policy in parity with base-macros/src/dependency_package.rs. The crates cannot
189    // share the implementation without adding the full SDK macro dependency graph.
190    push_profile_dirs(&mut dirs, project_dir.join("target"), profiles);
191    push_manifest_ancestor_target_profile_dirs(&mut dirs, project_dir, profiles);
192    push_ancestor_target_profile_dirs(&mut dirs, project_dir, profiles);
193
194    if let Some(target_dir) = env::var_os("CARGO_TARGET_DIR") {
195        push_profile_dirs(&mut dirs, PathBuf::from(target_dir), profiles);
196    }
197    if let Some(out_dir) = env::var_os("OUT_DIR") {
198        for ancestor in Path::new(&out_dir).ancestors() {
199            push_profile_dirs(&mut dirs, ancestor.to_path_buf(), profiles);
200        }
201    }
202    if let Ok(current_dir) = env::current_dir() {
203        push_profile_dirs(&mut dirs, current_dir.join("target"), profiles);
204        push_manifest_ancestor_target_profile_dirs(&mut dirs, &current_dir, profiles);
205        push_ancestor_target_profile_dirs(&mut dirs, &current_dir, profiles);
206    }
207    dirs
208}
209
210/// Adds `target/miden/<profile>` directories while preserving order.
211fn push_profile_dirs(dirs: &mut Vec<PathBuf>, target_root: PathBuf, profiles: &[String]) {
212    for profile in profiles {
213        let dir = target_root.join("miden").join(profile);
214        if !dirs.contains(&dir) {
215            dirs.push(dir);
216        }
217    }
218}
219
220/// Adds target directories found among the ancestors of `path`.
221fn push_ancestor_target_profile_dirs(dirs: &mut Vec<PathBuf>, path: &Path, profiles: &[String]) {
222    for ancestor in path.ancestors() {
223        if ancestor.file_name().is_some_and(|name| name == "target") {
224            push_profile_dirs(dirs, ancestor.to_path_buf(), profiles);
225        }
226    }
227}
228
229/// Adds target directories for Cargo manifest ancestors of `path`.
230fn push_manifest_ancestor_target_profile_dirs(
231    dirs: &mut Vec<PathBuf>,
232    path: &Path,
233    profiles: &[String],
234) {
235    for ancestor in path.ancestors() {
236        if ancestor.join("Cargo.toml").is_file() || ancestor.join("Cargo.lock").is_file() {
237            push_profile_dirs(dirs, ancestor.join("target"), profiles);
238        }
239    }
240}
241
242/// Finds a project package in the build-owned package cache.
243fn find_project_package_in_cache(dir: &Path, stems: &[String]) -> Option<PathBuf> {
244    stems
245        .iter()
246        .map(|stem| dir.join(package_cache::package_file_name(stem)))
247        .find(|path| path.is_file())
248}
249
250/// Finds a package in one output directory by ordered package identity.
251fn find_project_package_in_dir(dir: &Path, stems: &[String]) -> Result<Option<PathBuf>> {
252    if !dir.is_dir() {
253        return Ok(None);
254    }
255
256    let packages = fs::read_dir(dir)
257        .map_err(|error| Error::new(format!("failed to read '{}': {error}", dir.display())))?
258        .collect::<core::result::Result<Vec<_>, _>>()
259        .map_err(|error| {
260            Error::new(format!("failed to read an entry in '{}': {error}", dir.display()))
261        })?
262        .into_iter()
263        .map(|entry| entry.path())
264        .filter(|path| {
265            path.is_file() && path.extension().is_some_and(|extension| extension == "masp")
266        })
267        .collect::<Vec<_>>();
268
269    // The stems are ordered by identity. The canonical miden-project package name comes first,
270    // followed by legacy aliases. This matches base-macros/src/dependency_package.rs and prevents
271    // a newer legacy artifact from shadowing the canonical package.
272    for stem in stems {
273        if let Some(package) = packages
274            .iter()
275            .find(|path| path.file_stem().and_then(|value| value.to_str()) == Some(stem.as_str()))
276        {
277            return Ok(Some(package.clone()));
278        }
279    }
280    Ok(None)
281}
282
283/// The package file name stems of one project and the manifests they came from.
284struct ProjectIdentity {
285    /// Ordered package file name stems, canonical identity first.
286    stems: Vec<String>,
287    /// The manifests that were read, in the order they were read.
288    manifests: Vec<PathBuf>,
289}
290
291/// Returns ordered package filename stems for one project, and the manifests they came from.
292fn project_identity(project_dir: &Path) -> ProjectIdentity {
293    let mut stems = Vec::new();
294    let mut manifests = Vec::new();
295    for manifest in [project_dir.join("miden-project.toml"), project_dir.join("Cargo.toml")] {
296        if let Some(name) = package_name_from_manifest(&manifest) {
297            push_package_stem(&mut stems, &name);
298            manifests.push(manifest);
299        }
300    }
301    if let Some(name) = project_dir.file_name().and_then(|name| name.to_str()) {
302        push_package_stem(&mut stems, name);
303    }
304    ProjectIdentity { stems, manifests }
305}
306
307/// Reads a package name from one TOML manifest.
308fn package_name_from_manifest(manifest_path: &Path) -> Option<String> {
309    let manifest = fs::read_to_string(manifest_path).ok()?;
310    let manifest = manifest.parse::<toml::Table>().ok()?;
311    manifest
312        .get("package")
313        .and_then(toml::Value::as_table)
314        .and_then(|package| package.get("name"))
315        .and_then(toml::Value::as_str)
316        .map(ToOwned::to_owned)
317}
318
319/// Adds a package filename stem and its underscore alias.
320fn push_package_stem(stems: &mut Vec<String>, name: &str) {
321    if !name.is_empty() && !stems.iter().any(|existing| existing == name) {
322        stems.push(name.to_owned());
323    }
324    let normalized = name.replace('-', "_");
325    if !normalized.is_empty() && !stems.contains(&normalized) {
326        stems.push(normalized);
327    }
328}
329
330/// Makes a cache path absolute for generated `include_bytes!` inputs.
331fn absolutize(path: PathBuf) -> Result<PathBuf> {
332    if path.is_absolute() {
333        return Ok(path);
334    }
335    Ok(env::current_dir()
336        .map_err(|error| Error::new(format!("failed to resolve current directory: {error}")))?
337        .join(path))
338}
339
340/// Adds one profile name once.
341fn push_profile(profiles: &mut Vec<String>, profile: String) {
342    if !profile.is_empty() && !profiles.contains(&profile) {
343        profiles.push(profile);
344    }
345}
346
347/// Formats the diagnostic for a project without a built package.
348fn missing_project_package_message(
349    macro_crate: &str,
350    project_dir: &Path,
351    cache_dir: Option<&Path>,
352    stems: &[String],
353) -> String {
354    let manifest = project_dir.join("Cargo.toml");
355    let build = if manifest.is_file() {
356        format!("cargo miden build --manifest-path {} --release", manifest.display())
357    } else {
358        "cargo miden build --release".to_owned()
359    };
360    if let Some(cache_dir) = cache_dir {
361        let expected_files = stems
362            .iter()
363            .map(|stem| format!("'{}'", package_cache::package_file_name(stem)))
364            .collect::<Vec<_>>()
365            .join(", ");
366        return format!(
367            "{macro_crate} could not find a built `.masp` package for note project '{}'. Expected \
368             one of these package names: {expected_files}. Searched {} directory '{}'. Build the \
369             note project first with `{build}` so the package is available during macro expansion.",
370            project_dir.display(),
371            package_cache::PACKAGE_CACHE_ENV,
372            cache_dir.display(),
373        );
374    }
375    format!(
376        "{macro_crate} could not find a built `.masp` package under '{}'. Build the note project \
377         first with `{build}`.",
378        project_dir.join("target/miden/<profile>").display()
379    )
380}
381
382#[cfg(test)]
383mod tests {
384    use std::fs;
385
386    use super::{
387        find_project_package_in_cache, find_project_package_in_dir,
388        missing_project_package_message, project_identity, project_output_dirs,
389    };
390
391    #[test]
392    fn canonical_project_identity_wins_over_legacy_aliases() {
393        let temp = tempfile::tempdir().unwrap();
394        fs::write(
395            temp.path().join("miden-project.toml"),
396            "[package]\nname='canonical-note'\nversion='0.1.0'",
397        )
398        .unwrap();
399        fs::write(temp.path().join("Cargo.toml"), "[package]\nname='legacy-note'\nversion='0.1.0'")
400            .unwrap();
401        let output = temp.path().join("output");
402        fs::create_dir(&output).unwrap();
403        fs::write(output.join("legacy-note.masp"), b"newer legacy package").unwrap();
404        fs::write(output.join("canonical-note.masp"), b"canonical package").unwrap();
405
406        let stems = project_identity(temp.path()).stems;
407        let selected = find_project_package_in_dir(&output, &stems).unwrap().unwrap();
408        assert_eq!(selected, output.join("canonical-note.masp"));
409    }
410
411    #[test]
412    fn tracked_inputs_name_the_manifests_that_were_read() {
413        let temp = tempfile::tempdir().unwrap();
414        fs::write(
415            temp.path().join("miden-project.toml"),
416            "[package]\nname='canonical-note'\nversion='0.1.0'",
417        )
418        .unwrap();
419        fs::write(temp.path().join("Cargo.toml"), "[package]\nname='legacy-note'\nversion='0.1.0'")
420            .unwrap();
421
422        let identity = project_identity(temp.path());
423
424        assert_eq!(
425            identity.manifests,
426            [temp.path().join("miden-project.toml"), temp.path().join("Cargo.toml")]
427        );
428    }
429
430    #[test]
431    fn tracked_inputs_skip_a_manifest_that_is_absent() {
432        let temp = tempfile::tempdir().unwrap();
433        fs::write(temp.path().join("Cargo.toml"), "[package]\nname='legacy-note'\nversion='0.1.0'")
434            .unwrap();
435
436        let identity = project_identity(temp.path());
437
438        assert_eq!(identity.manifests, [temp.path().join("Cargo.toml")]);
439    }
440
441    #[test]
442    fn manifest_ancestor_target_directories_are_candidates() {
443        let temp = tempfile::tempdir().unwrap();
444        fs::write(temp.path().join("Cargo.lock"), "").unwrap();
445        let project = temp.path().join("crates/note");
446        fs::create_dir_all(&project).unwrap();
447        let profiles = vec!["release".to_owned()];
448
449        let dirs = project_output_dirs(&project, &profiles);
450
451        assert!(dirs.contains(&temp.path().join("target/miden/release")));
452    }
453
454    #[test]
455    fn missing_package_diagnostic_names_macro_and_build_command() {
456        let temp = tempfile::tempdir().unwrap();
457        fs::write(temp.path().join("Cargo.toml"), "[package]\nname='note'\nversion='0.1.0'")
458            .unwrap();
459        let stems = project_identity(temp.path()).stems;
460        let message = missing_project_package_message("test-note-macro", temp.path(), None, &stems);
461        assert!(message.contains("test-note-macro"));
462        assert!(message.contains("cargo miden build --manifest-path"));
463        assert!(message.contains("--release"));
464    }
465
466    #[test]
467    fn missing_cached_package_diagnostic_names_cache_and_expected_files() {
468        let temp = tempfile::tempdir().unwrap();
469        fs::write(temp.path().join("Cargo.toml"), "[package]\nname='test-note'\nversion='0.1.0'")
470            .unwrap();
471        let cache_dir = temp.path().join("package-cache");
472        let stems = project_identity(temp.path()).stems;
473
474        let message = missing_project_package_message(
475            "test-note-macro",
476            temp.path(),
477            Some(&cache_dir),
478            &stems,
479        );
480
481        assert!(message.contains(&cache_dir.display().to_string()));
482        assert!(message.contains("'test-note.masp'"));
483        assert!(message.contains("MIDENC_PACKAGE_CACHE"));
484        assert!(!message.contains("target/miden/<profile>"));
485    }
486
487    #[test]
488    fn cache_lookup_uses_the_shared_package_file_name() {
489        let temp = tempfile::tempdir().unwrap();
490        let package = temp.path().join("note.with.dot.masp");
491        fs::write(&package, b"package").unwrap();
492
493        let found = find_project_package_in_cache(temp.path(), &["note.with.dot".to_owned()]);
494
495        assert_eq!(found, Some(package));
496    }
497}