1use 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
13pub struct NotePackageArtifact {
15 path: PathBuf,
16 schema: NoteStorageSchema,
17 tracked_inputs: Vec<PathBuf>,
18}
19
20impl NotePackageArtifact {
21 pub fn path(&self) -> &Path {
23 &self.path
24 }
25
26 pub const fn schema(&self) -> &NoteStorageSchema {
28 &self.schema
29 }
30
31 pub fn tracked_inputs(&self) -> &[PathBuf] {
37 &self.tracked_inputs
38 }
39}
40
41pub struct NotePackageResolver<'a> {
43 macro_crate: &'a str,
44}
45
46impl<'a> NotePackageResolver<'a> {
47 pub const fn new(macro_crate: &'a str) -> Self {
49 Self { macro_crate }
50 }
51
52 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 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 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 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
142fn 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
164fn 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
172fn 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
184fn project_output_dirs(project_dir: &Path, profiles: &[String]) -> Vec<PathBuf> {
186 let mut dirs = Vec::new();
187
188 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, ¤t_dir, profiles);
205 push_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles);
206 }
207 dirs
208}
209
210fn 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
220fn 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
229fn 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
242fn 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
250fn 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 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
283struct ProjectIdentity {
285 stems: Vec<String>,
287 manifests: Vec<PathBuf>,
289}
290
291fn 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
307fn 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
319fn 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
330fn 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
340fn push_profile(profiles: &mut Vec<String>, profile: String) {
342 if !profile.is_empty() && !profiles.contains(&profile) {
343 profiles.push(profile);
344 }
345}
346
347fn 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}