use std::{
env, fs,
path::{Path, PathBuf},
};
use miden_mast_package::Package;
use midenc_frontend_wasm_metadata::package_cache;
use crate::{Error, NoteStorageSchema, Result};
pub struct NotePackageArtifact {
path: PathBuf,
schema: NoteStorageSchema,
tracked_inputs: Vec<PathBuf>,
}
impl NotePackageArtifact {
pub fn path(&self) -> &Path {
&self.path
}
pub const fn schema(&self) -> &NoteStorageSchema {
&self.schema
}
pub fn tracked_inputs(&self) -> &[PathBuf] {
&self.tracked_inputs
}
}
pub struct NotePackageResolver<'a> {
macro_crate: &'a str,
}
impl<'a> NotePackageResolver<'a> {
pub const fn new(macro_crate: &'a str) -> Self {
Self { macro_crate }
}
pub fn from_project(&self, project: &str) -> Result<NotePackageArtifact> {
let project_dir = self.resolve_manifest_path(project)?;
if !project_dir.is_dir() {
return Err(Error::new(format!(
"{}: note project directory '{}' does not exist",
self.macro_crate,
project_dir.display()
)));
}
let identity = project_identity(&project_dir);
let stems = &identity.stems;
let cache_dir = package_cache_dir()
.map_err(|error| Error::new(format!("{}: {error}", self.macro_crate)))?;
let package_path = resolve_project_package(&project_dir, stems, cache_dir.as_deref())
.map_err(|error| Error::new(format!("{}: {error}", self.macro_crate)))?
.ok_or_else(|| {
Error::new(missing_project_package_message(
self.macro_crate,
&project_dir,
cache_dir.as_deref(),
stems,
))
})?;
self.load_package(package_path, identity.manifests)
}
pub fn from_package(&self, package: &str) -> Result<NotePackageArtifact> {
let package_path = self.resolve_manifest_path(package)?;
if !package_path.is_file() {
return Err(Error::new(format!(
"{}: Miden package '{}' does not exist",
self.macro_crate,
package_path.display()
)));
}
self.load_package(package_path, Vec::new())
}
fn resolve_manifest_path(&self, value: &str) -> Result<PathBuf> {
let path = PathBuf::from(value);
if path.is_absolute() {
return Ok(path);
}
let manifest_dir = env::var_os("CARGO_MANIFEST_DIR").ok_or_else(|| {
Error::new(format!(
"{}: CARGO_MANIFEST_DIR is not set during macro expansion",
self.macro_crate
))
})?;
Ok(PathBuf::from(manifest_dir).join(path))
}
fn load_package(
&self,
path: PathBuf,
tracked_inputs: Vec<PathBuf>,
) -> Result<NotePackageArtifact> {
let path = path.canonicalize().map_err(|error| {
Error::new(format!(
"{}: failed to resolve Miden package '{}': {error}",
self.macro_crate,
path.display()
))
})?;
let package = Package::deserialize_from_file(&path).map_err(|error| {
Error::new(format!(
"{}: failed to read Miden package '{}': {error}",
self.macro_crate,
path.display()
))
})?;
let schema = NoteStorageSchema::from_package(&package).map_err(|error| {
Error::new(format!(
"{}: failed to read note storage schema from '{}': {error}",
self.macro_crate,
path.display()
))
})?;
Ok(NotePackageArtifact {
path,
schema,
tracked_inputs,
})
}
}
fn resolve_project_package(
project_dir: &Path,
stems: &[String],
cache_dir: Option<&Path>,
) -> Result<Option<PathBuf>> {
if let Some(cache_dir) = cache_dir {
return Ok(find_project_package_in_cache(cache_dir, stems));
}
let profiles = candidate_profiles();
for output_dir in project_output_dirs(project_dir, &profiles) {
if let Some(package) = find_project_package_in_dir(&output_dir, stems)? {
return Ok(Some(package));
}
}
Ok(None)
}
fn package_cache_dir() -> Result<Option<PathBuf>> {
env::var_os(package_cache::PACKAGE_CACHE_ENV)
.filter(|value| !value.is_empty())
.map(|value| absolutize(PathBuf::from(value)))
.transpose()
}
fn candidate_profiles() -> Vec<String> {
let mut profiles = Vec::new();
if let Ok(profile) = env::var("PROFILE") {
push_profile(&mut profiles, profile);
}
push_profile(&mut profiles, "release".to_owned());
push_profile(&mut profiles, "debug".to_owned());
push_profile(&mut profiles, "dev".to_owned());
profiles
}
fn project_output_dirs(project_dir: &Path, profiles: &[String]) -> Vec<PathBuf> {
let mut dirs = Vec::new();
push_profile_dirs(&mut dirs, project_dir.join("target"), profiles);
push_manifest_ancestor_target_profile_dirs(&mut dirs, project_dir, profiles);
push_ancestor_target_profile_dirs(&mut dirs, project_dir, profiles);
if let Some(target_dir) = env::var_os("CARGO_TARGET_DIR") {
push_profile_dirs(&mut dirs, PathBuf::from(target_dir), profiles);
}
if let Some(out_dir) = env::var_os("OUT_DIR") {
for ancestor in Path::new(&out_dir).ancestors() {
push_profile_dirs(&mut dirs, ancestor.to_path_buf(), profiles);
}
}
if let Ok(current_dir) = env::current_dir() {
push_profile_dirs(&mut dirs, current_dir.join("target"), profiles);
push_manifest_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles);
push_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles);
}
dirs
}
fn push_profile_dirs(dirs: &mut Vec<PathBuf>, target_root: PathBuf, profiles: &[String]) {
for profile in profiles {
let dir = target_root.join("miden").join(profile);
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
}
fn push_ancestor_target_profile_dirs(dirs: &mut Vec<PathBuf>, path: &Path, profiles: &[String]) {
for ancestor in path.ancestors() {
if ancestor.file_name().is_some_and(|name| name == "target") {
push_profile_dirs(dirs, ancestor.to_path_buf(), profiles);
}
}
}
fn push_manifest_ancestor_target_profile_dirs(
dirs: &mut Vec<PathBuf>,
path: &Path,
profiles: &[String],
) {
for ancestor in path.ancestors() {
if ancestor.join("Cargo.toml").is_file() || ancestor.join("Cargo.lock").is_file() {
push_profile_dirs(dirs, ancestor.join("target"), profiles);
}
}
}
fn find_project_package_in_cache(dir: &Path, stems: &[String]) -> Option<PathBuf> {
stems
.iter()
.map(|stem| dir.join(package_cache::package_file_name(stem)))
.find(|path| path.is_file())
}
fn find_project_package_in_dir(dir: &Path, stems: &[String]) -> Result<Option<PathBuf>> {
if !dir.is_dir() {
return Ok(None);
}
let packages = fs::read_dir(dir)
.map_err(|error| Error::new(format!("failed to read '{}': {error}", dir.display())))?
.collect::<core::result::Result<Vec<_>, _>>()
.map_err(|error| {
Error::new(format!("failed to read an entry in '{}': {error}", dir.display()))
})?
.into_iter()
.map(|entry| entry.path())
.filter(|path| {
path.is_file() && path.extension().is_some_and(|extension| extension == "masp")
})
.collect::<Vec<_>>();
for stem in stems {
if let Some(package) = packages
.iter()
.find(|path| path.file_stem().and_then(|value| value.to_str()) == Some(stem.as_str()))
{
return Ok(Some(package.clone()));
}
}
Ok(None)
}
struct ProjectIdentity {
stems: Vec<String>,
manifests: Vec<PathBuf>,
}
fn project_identity(project_dir: &Path) -> ProjectIdentity {
let mut stems = Vec::new();
let mut manifests = Vec::new();
for manifest in [project_dir.join("miden-project.toml"), project_dir.join("Cargo.toml")] {
if let Some(name) = package_name_from_manifest(&manifest) {
push_package_stem(&mut stems, &name);
manifests.push(manifest);
}
}
if let Some(name) = project_dir.file_name().and_then(|name| name.to_str()) {
push_package_stem(&mut stems, name);
}
ProjectIdentity { stems, manifests }
}
fn package_name_from_manifest(manifest_path: &Path) -> Option<String> {
let manifest = fs::read_to_string(manifest_path).ok()?;
let manifest = manifest.parse::<toml::Table>().ok()?;
manifest
.get("package")
.and_then(toml::Value::as_table)
.and_then(|package| package.get("name"))
.and_then(toml::Value::as_str)
.map(ToOwned::to_owned)
}
fn push_package_stem(stems: &mut Vec<String>, name: &str) {
if !name.is_empty() && !stems.iter().any(|existing| existing == name) {
stems.push(name.to_owned());
}
let normalized = name.replace('-', "_");
if !normalized.is_empty() && !stems.contains(&normalized) {
stems.push(normalized);
}
}
fn absolutize(path: PathBuf) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path);
}
Ok(env::current_dir()
.map_err(|error| Error::new(format!("failed to resolve current directory: {error}")))?
.join(path))
}
fn push_profile(profiles: &mut Vec<String>, profile: String) {
if !profile.is_empty() && !profiles.contains(&profile) {
profiles.push(profile);
}
}
fn missing_project_package_message(
macro_crate: &str,
project_dir: &Path,
cache_dir: Option<&Path>,
stems: &[String],
) -> String {
let manifest = project_dir.join("Cargo.toml");
let build = if manifest.is_file() {
format!("cargo miden build --manifest-path {} --release", manifest.display())
} else {
"cargo miden build --release".to_owned()
};
if let Some(cache_dir) = cache_dir {
let expected_files = stems
.iter()
.map(|stem| format!("'{}'", package_cache::package_file_name(stem)))
.collect::<Vec<_>>()
.join(", ");
return format!(
"{macro_crate} could not find a built `.masp` package for note project '{}'. Expected \
one of these package names: {expected_files}. Searched {} directory '{}'. Build the \
note project first with `{build}` so the package is available during macro expansion.",
project_dir.display(),
package_cache::PACKAGE_CACHE_ENV,
cache_dir.display(),
);
}
format!(
"{macro_crate} could not find a built `.masp` package under '{}'. Build the note project \
first with `{build}`.",
project_dir.join("target/miden/<profile>").display()
)
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{
find_project_package_in_cache, find_project_package_in_dir,
missing_project_package_message, project_identity, project_output_dirs,
};
#[test]
fn canonical_project_identity_wins_over_legacy_aliases() {
let temp = tempfile::tempdir().unwrap();
fs::write(
temp.path().join("miden-project.toml"),
"[package]\nname='canonical-note'\nversion='0.1.0'",
)
.unwrap();
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='legacy-note'\nversion='0.1.0'")
.unwrap();
let output = temp.path().join("output");
fs::create_dir(&output).unwrap();
fs::write(output.join("legacy-note.masp"), b"newer legacy package").unwrap();
fs::write(output.join("canonical-note.masp"), b"canonical package").unwrap();
let stems = project_identity(temp.path()).stems;
let selected = find_project_package_in_dir(&output, &stems).unwrap().unwrap();
assert_eq!(selected, output.join("canonical-note.masp"));
}
#[test]
fn tracked_inputs_name_the_manifests_that_were_read() {
let temp = tempfile::tempdir().unwrap();
fs::write(
temp.path().join("miden-project.toml"),
"[package]\nname='canonical-note'\nversion='0.1.0'",
)
.unwrap();
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='legacy-note'\nversion='0.1.0'")
.unwrap();
let identity = project_identity(temp.path());
assert_eq!(
identity.manifests,
[temp.path().join("miden-project.toml"), temp.path().join("Cargo.toml")]
);
}
#[test]
fn tracked_inputs_skip_a_manifest_that_is_absent() {
let temp = tempfile::tempdir().unwrap();
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='legacy-note'\nversion='0.1.0'")
.unwrap();
let identity = project_identity(temp.path());
assert_eq!(identity.manifests, [temp.path().join("Cargo.toml")]);
}
#[test]
fn manifest_ancestor_target_directories_are_candidates() {
let temp = tempfile::tempdir().unwrap();
fs::write(temp.path().join("Cargo.lock"), "").unwrap();
let project = temp.path().join("crates/note");
fs::create_dir_all(&project).unwrap();
let profiles = vec!["release".to_owned()];
let dirs = project_output_dirs(&project, &profiles);
assert!(dirs.contains(&temp.path().join("target/miden/release")));
}
#[test]
fn missing_package_diagnostic_names_macro_and_build_command() {
let temp = tempfile::tempdir().unwrap();
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='note'\nversion='0.1.0'")
.unwrap();
let stems = project_identity(temp.path()).stems;
let message = missing_project_package_message("test-note-macro", temp.path(), None, &stems);
assert!(message.contains("test-note-macro"));
assert!(message.contains("cargo miden build --manifest-path"));
assert!(message.contains("--release"));
}
#[test]
fn missing_cached_package_diagnostic_names_cache_and_expected_files() {
let temp = tempfile::tempdir().unwrap();
fs::write(temp.path().join("Cargo.toml"), "[package]\nname='test-note'\nversion='0.1.0'")
.unwrap();
let cache_dir = temp.path().join("package-cache");
let stems = project_identity(temp.path()).stems;
let message = missing_project_package_message(
"test-note-macro",
temp.path(),
Some(&cache_dir),
&stems,
);
assert!(message.contains(&cache_dir.display().to_string()));
assert!(message.contains("'test-note.masp'"));
assert!(message.contains("MIDENC_PACKAGE_CACHE"));
assert!(!message.contains("target/miden/<profile>"));
}
#[test]
fn cache_lookup_uses_the_shared_package_file_name() {
let temp = tempfile::tempdir().unwrap();
let package = temp.path().join("note.with.dot.masp");
fs::write(&package, b"package").unwrap();
let found = find_project_package_in_cache(temp.path(), &["note.with.dot".to_owned()]);
assert_eq!(found, Some(package));
}
}