Skip to main content

petri_artifacts_core/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Core abstractions for declaring and resolving type-safe test artifacts in
5//! `petri`.
6//!
7//! NOTE: this crate does not define any concrete Artifact types itself.
8
9#![forbid(unsafe_code)]
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15// exported to support the `declare_artifacts!` macro
16#[doc(hidden)]
17pub use paste;
18use std::cell::RefCell;
19use std::ffi::OsStr;
20use std::marker::PhantomData;
21use std::path::Path;
22
23/// How an artifact can be accessed.
24#[derive(Debug, Clone)]
25pub enum ArtifactSource {
26    /// Artifact is available as a local file.
27    Local(PathBuf),
28    /// Artifact is available at a remote URL (not yet downloaded).
29    Remote {
30        /// The URL where the artifact can be fetched.
31        url: String,
32    },
33}
34
35/// Whether remote artifact access is allowed for a particular requirement.
36#[derive(Debug, Copy, Clone, PartialEq, Eq)]
37pub enum RemoteAccess {
38    /// Allow the artifact to resolve to a remote URL if not available locally.
39    Allow,
40    /// Require a local file; fail if not available locally.
41    LocalOnly,
42}
43
44/// A trait that marks a type as being the type-safe ID for a petri artifact.
45///
46/// This trait should never be implemented manually! It will be automatically
47/// implemented on the correct type when declaring artifacts using
48/// [`declare_artifacts!`](crate::declare_artifacts).
49pub trait ArtifactId: 'static {
50    /// A globally unique ID corresponding to this artifact.
51    #[doc(hidden)]
52    const GLOBAL_UNIQUE_ID: &'static str;
53
54    /// ...in case you decide to flaunt the trait-level docs regarding manually
55    /// implementing this trait.
56    #[doc(hidden)]
57    fn i_know_what_im_doing_with_this_manual_impl_instead_of_using_the_declare_artifacts_macro();
58}
59
60/// A type-safe handle to a particular Artifact, as declared using the
61/// [`declare_artifacts!`](crate::declare_artifacts) macro.
62#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
63pub struct ArtifactHandle<A: ArtifactId>(PhantomData<A>);
64
65impl<A: ArtifactId + std::fmt::Debug> std::fmt::Debug for ArtifactHandle<A> {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        std::fmt::Debug::fmt(&self.erase(), f)
68    }
69}
70
71/// A resolved artifact path for artifact `A`.
72pub struct ResolvedArtifact<A = ()>(Option<PathBuf>, PhantomData<A>);
73
74impl<A> Clone for ResolvedArtifact<A> {
75    fn clone(&self) -> Self {
76        Self(self.0.clone(), self.1)
77    }
78}
79
80impl<A> std::fmt::Debug for ResolvedArtifact<A> {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_tuple("ResolvedArtifact").field(&self.0).finish()
83    }
84}
85
86impl<A> ResolvedArtifact<A> {
87    /// Erases the type `A`.
88    pub fn erase(self) -> ResolvedArtifact {
89        ResolvedArtifact(self.0, PhantomData)
90    }
91
92    /// Gets the resolved path of the artifact.
93    #[track_caller]
94    pub fn get(&self) -> &Path {
95        self.0
96            .as_ref()
97            .expect("cannot get path in requirements phase")
98    }
99}
100
101impl<A> From<ResolvedArtifact<A>> for PathBuf {
102    #[track_caller]
103    fn from(ra: ResolvedArtifact<A>) -> PathBuf {
104        ra.0.expect("cannot get path in requirements phase")
105    }
106}
107
108impl<A> AsRef<Path> for ResolvedArtifact<A> {
109    #[track_caller]
110    fn as_ref(&self) -> &Path {
111        self.get()
112    }
113}
114
115impl<A> AsRef<OsStr> for ResolvedArtifact<A> {
116    #[track_caller]
117    fn as_ref(&self) -> &OsStr {
118        self.get().as_ref()
119    }
120}
121
122/// A resolve artifact path for an optional artifact `A`.
123#[derive(Clone, Debug)]
124pub struct ResolvedOptionalArtifact<A = ()>(OptionalArtifactState, PhantomData<A>);
125
126#[derive(Clone, Debug)]
127enum OptionalArtifactState {
128    Collecting,
129    Missing,
130    Present(PathBuf),
131}
132
133impl<A> ResolvedOptionalArtifact<A> {
134    /// Erases the type `A`.
135    pub fn erase(self) -> ResolvedOptionalArtifact {
136        ResolvedOptionalArtifact(self.0, PhantomData)
137    }
138
139    /// Gets the resolved path of the artifact, if it was found.
140    #[track_caller]
141    pub fn get(&self) -> Option<&Path> {
142        match self.0 {
143            OptionalArtifactState::Collecting => panic!("cannot get path in requirements phase"),
144            OptionalArtifactState::Missing => None,
145            OptionalArtifactState::Present(ref path) => Some(path),
146        }
147    }
148}
149
150/// A resolved artifact source for artifact `A`, which may be local or remote.
151pub struct ResolvedArtifactSource<A = ()>(Option<ArtifactSource>, PhantomData<A>);
152
153impl<A> Clone for ResolvedArtifactSource<A> {
154    fn clone(&self) -> Self {
155        Self(self.0.clone(), self.1)
156    }
157}
158
159impl<A> std::fmt::Debug for ResolvedArtifactSource<A> {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_tuple("ResolvedArtifactSource")
162            .field(&self.0)
163            .finish()
164    }
165}
166
167impl<A> ResolvedArtifactSource<A> {
168    /// Erases the type `A`.
169    pub fn erase(self) -> ResolvedArtifactSource {
170        ResolvedArtifactSource(self.0, PhantomData)
171    }
172
173    /// Gets the resolved source of the artifact.
174    #[track_caller]
175    pub fn get(&self) -> &ArtifactSource {
176        self.0
177            .as_ref()
178            .expect("cannot get source in requirements phase")
179    }
180}
181
182/// An artifact resolver, used both to express requirements for artifacts and to
183/// resolve them to paths.
184pub struct ArtifactResolver<'a> {
185    inner: ArtifactResolverInner<'a>,
186    remote_policy: RemoteAccess,
187}
188
189impl<'a> ArtifactResolver<'a> {
190    /// Returns the default remote access policy, checking the
191    /// `PETRI_REMOTE_ARTIFACTS` environment variable.
192    ///
193    /// Set `PETRI_REMOTE_ARTIFACTS=0` to force all artifacts to be resolved
194    /// locally, even if `RemoteAccess::Allow` is specified per-call.
195    fn default_remote_policy() -> RemoteAccess {
196        match std::env::var("PETRI_REMOTE_ARTIFACTS").as_deref() {
197            Ok("0") | Ok("false") => RemoteAccess::LocalOnly,
198            _ => RemoteAccess::Allow,
199        }
200    }
201
202    /// Returns a resolver to collect requirements; the artifact objects returned by
203    /// [`require`](Self::require) will panic if used.
204    pub fn collector(requirements: &'a mut TestArtifactRequirements) -> Self {
205        ArtifactResolver {
206            inner: ArtifactResolverInner::Collecting(RefCell::new(requirements)),
207            remote_policy: Self::default_remote_policy(),
208        }
209    }
210
211    /// Returns a resolver to resolve artifacts.
212    pub fn resolver(artifacts: &'a TestArtifacts) -> Self {
213        ArtifactResolver {
214            inner: ArtifactResolverInner::Resolving(artifacts),
215            remote_policy: Self::default_remote_policy(),
216        }
217    }
218
219    /// Returns the effective remote access for a given per-call policy,
220    /// respecting the resolver-wide policy.
221    fn effective_remote(&self, per_call: RemoteAccess) -> RemoteAccess {
222        if matches!(self.remote_policy, RemoteAccess::LocalOnly) {
223            RemoteAccess::LocalOnly
224        } else {
225            per_call
226        }
227    }
228
229    /// Resolve a required artifact. The artifact must be available locally.
230    pub fn require<A: ArtifactId>(&self, handle: ArtifactHandle<A>) -> ResolvedArtifact<A> {
231        let source = self.require_source(handle, RemoteAccess::LocalOnly);
232        ResolvedArtifact(
233            source.0.map(|s| match s {
234                ArtifactSource::Local(p) => p,
235                ArtifactSource::Remote { url } => panic!(
236                    "artifact required via require() resolved to remote source `{url}`; \
237                     use require_source(..., RemoteAccess::Allow) or download the artifact locally"
238                ),
239            }),
240            PhantomData,
241        )
242    }
243
244    /// Resolve an optional artifact.
245    pub fn try_require<A: ArtifactId>(
246        &self,
247        handle: ArtifactHandle<A>,
248    ) -> ResolvedOptionalArtifact<A> {
249        match &self.inner {
250            ArtifactResolverInner::Collecting(requirements) => {
251                requirements.borrow_mut().try_require(handle.erase());
252                ResolvedOptionalArtifact(OptionalArtifactState::Collecting, PhantomData)
253            }
254            ArtifactResolverInner::Resolving(artifacts) => ResolvedOptionalArtifact(
255                artifacts
256                    .try_get(handle)
257                    .map_or(OptionalArtifactState::Missing, |p| {
258                        OptionalArtifactState::Present(p.to_owned())
259                    }),
260                PhantomData,
261            ),
262        }
263    }
264
265    /// Resolve an artifact, returning either a local path or a remote URL.
266    ///
267    /// The `remote` parameter controls whether a remote URL is acceptable for
268    /// this particular artifact. The resolver's configured remote policy may
269    /// further restrict this request and force the effective access mode to
270    /// `LocalOnly`.
271    pub fn require_source<A: ArtifactId>(
272        &self,
273        handle: ArtifactHandle<A>,
274        remote: RemoteAccess,
275    ) -> ResolvedArtifactSource<A> {
276        let effective = self.effective_remote(remote);
277        match &self.inner {
278            ArtifactResolverInner::Collecting(requirements) => {
279                requirements
280                    .borrow_mut()
281                    .require_source(handle.erase(), effective);
282                ResolvedArtifactSource(None, PhantomData)
283            }
284            ArtifactResolverInner::Resolving(artifacts) => {
285                ResolvedArtifactSource(Some(artifacts.get_source(handle).clone()), PhantomData)
286            }
287        }
288    }
289}
290
291enum ArtifactResolverInner<'a> {
292    Collecting(RefCell<&'a mut TestArtifactRequirements>),
293    Resolving(&'a TestArtifacts),
294}
295
296/// A type-erased handle to a particular Artifact, with no information as to
297/// what exactly the artifact is.
298#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
299pub struct ErasedArtifactHandle {
300    artifact_id_str: &'static str,
301}
302
303impl std::fmt::Debug for ErasedArtifactHandle {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        // the `declare_artifacts!` macro uses `module_path!` under-the-hood to
306        // generate an artifact_id_str based on the artifact's crate + module
307        // path. To avoid collisions, the mod is named `TYPE_NAME__ty`, but to
308        // make it easier to parse output, we strip the `__ty`.
309        write!(
310            f,
311            "{}",
312            self.artifact_id_str
313                .strip_suffix("__ty")
314                .unwrap_or(self.artifact_id_str)
315        )
316    }
317}
318
319impl<A: ArtifactId> PartialEq<ErasedArtifactHandle> for ArtifactHandle<A> {
320    fn eq(&self, other: &ErasedArtifactHandle) -> bool {
321        &self.erase() == other
322    }
323}
324
325impl<A: ArtifactId> PartialEq<ArtifactHandle<A>> for ErasedArtifactHandle {
326    fn eq(&self, other: &ArtifactHandle<A>) -> bool {
327        self == &other.erase()
328    }
329}
330
331impl<A: ArtifactId> ArtifactHandle<A> {
332    /// Create a new typed artifact handle. It is unlikely you will need to call
333    /// this directly.
334    pub const fn new() -> Self {
335        Self(PhantomData)
336    }
337}
338
339/// Helper trait to allow uniform handling of both typed and untyped artifact
340/// handles in various contexts.
341pub trait AsArtifactHandle {
342    /// Return a type-erased handle to the given artifact.
343    fn erase(&self) -> ErasedArtifactHandle;
344}
345
346impl AsArtifactHandle for ErasedArtifactHandle {
347    fn erase(&self) -> ErasedArtifactHandle {
348        *self
349    }
350}
351
352impl<A: ArtifactId> AsArtifactHandle for ArtifactHandle<A> {
353    fn erase(&self) -> ErasedArtifactHandle {
354        ErasedArtifactHandle {
355            artifact_id_str: A::GLOBAL_UNIQUE_ID,
356        }
357    }
358}
359
360/// Declare one or more type-safe artifacts.
361#[macro_export]
362macro_rules! declare_artifacts {
363    (
364        $(
365            $(#[$doc:meta])*
366            $name:ident
367        ),*
368        $(,)?
369    ) => {
370        $(
371            $crate::paste::paste! {
372                $(#[$doc])*
373                #[expect(non_camel_case_types)]
374                pub const $name: $crate::ArtifactHandle<$name> = $crate::ArtifactHandle::new();
375
376                #[doc = concat!("Type-tag for [`",  stringify!($name), "`]")]
377                #[expect(non_camel_case_types)]
378                #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
379                pub enum $name {}
380
381                #[expect(non_snake_case)]
382                mod [< $name __ty >] {
383                    impl $crate::ArtifactId for super::$name {
384                        const GLOBAL_UNIQUE_ID: &'static str = module_path!();
385                        fn i_know_what_im_doing_with_this_manual_impl_instead_of_using_the_declare_artifacts_macro() {}
386                    }
387                }
388            }
389        )*
390    };
391}
392
393/// A trait to resolve artifacts to paths.
394///
395/// Test authors are expected to use the [`TestArtifactRequirements`] and
396/// [`TestArtifacts`] abstractions to interact with artifacts, and should not
397/// use this API directly.
398pub trait ResolveTestArtifact {
399    /// Given an artifact handle, return its corresponding PathBuf.
400    ///
401    /// This method must use type-erased handles, as using typed artifact
402    /// handles in this API would cause the trait to no longer be object-safe.
403    fn resolve(&self, id: ErasedArtifactHandle) -> anyhow::Result<PathBuf>;
404
405    /// Given an artifact handle, return its source (local path or remote URL).
406    ///
407    /// The default implementation wraps the result of [`resolve`](Self::resolve)
408    /// in [`ArtifactSource::Local`]. Override this to return
409    /// [`ArtifactSource::Remote`] for artifacts that are available at a URL
410    /// but not downloaded locally.
411    fn resolve_source(&self, id: ErasedArtifactHandle) -> anyhow::Result<ArtifactSource> {
412        self.resolve(id).map(ArtifactSource::Local)
413    }
414}
415
416impl<T: ResolveTestArtifact + ?Sized> ResolveTestArtifact for &T {
417    fn resolve(&self, id: ErasedArtifactHandle) -> anyhow::Result<PathBuf> {
418        (**self).resolve(id)
419    }
420
421    fn resolve_source(&self, id: ErasedArtifactHandle) -> anyhow::Result<ArtifactSource> {
422        (**self).resolve_source(id)
423    }
424}
425
426/// How an artifact was required.
427#[derive(Debug, Copy, Clone)]
428struct ArtifactRequirement {
429    optional: bool,
430    remote: RemoteAccess,
431}
432
433/// A set of dependencies required to run a test.
434#[derive(Clone)]
435pub struct TestArtifactRequirements {
436    artifacts: Vec<(ErasedArtifactHandle, ArtifactRequirement)>,
437}
438
439impl TestArtifactRequirements {
440    /// Create an empty set of dependencies.
441    pub fn new() -> Self {
442        TestArtifactRequirements {
443            artifacts: Vec::new(),
444        }
445    }
446
447    /// Add a dependency to the set of required artifacts (must be local).
448    pub fn require(&mut self, dependency: impl AsArtifactHandle) -> &mut Self {
449        self.require_source(dependency, RemoteAccess::LocalOnly)
450    }
451
452    /// Add an optional dependency to the set of artifacts.
453    pub fn try_require(&mut self, dependency: impl AsArtifactHandle) -> &mut Self {
454        self.artifacts.push((
455            dependency.erase(),
456            ArtifactRequirement {
457                optional: true,
458                remote: RemoteAccess::LocalOnly,
459            },
460        ));
461        self
462    }
463
464    /// Add a dependency that may resolve to a remote URL.
465    pub fn require_source(
466        &mut self,
467        dependency: impl AsArtifactHandle,
468        remote: RemoteAccess,
469    ) -> &mut Self {
470        self.artifacts.push((
471            dependency.erase(),
472            ArtifactRequirement {
473                optional: false,
474                remote,
475            },
476        ));
477        self
478    }
479
480    /// Returns the current list of required depencencies.
481    pub fn required_artifacts(&self) -> impl Iterator<Item = ErasedArtifactHandle> + '_ {
482        self.artifacts
483            .iter()
484            .filter_map(|&(a, req)| (!req.optional).then_some(a))
485    }
486
487    /// Returns the current list of optional dependencies.
488    pub fn optional_artifacts(&self) -> impl Iterator<Item = ErasedArtifactHandle> + '_ {
489        self.artifacts
490            .iter()
491            .filter_map(|&(a, req)| req.optional.then_some(a))
492    }
493
494    /// Resolve the set of dependencies.
495    ///
496    /// Remote access for each artifact is determined by the
497    /// [`RemoteAccess`] flags recorded during collection, subject to any
498    /// process-wide override configured via the `PETRI_REMOTE_ARTIFACTS`
499    /// environment variable.
500    pub fn resolve(&self, resolver: impl ResolveTestArtifact) -> anyhow::Result<TestArtifacts> {
501        let mut failed = String::new();
502        let mut resolved = HashMap::new();
503
504        // Merge duplicate registrations by handle, keeping the strictest
505        // requirement (treat as required if any registration is required,
506        // use the most restrictive remote access).
507        let mut merged: HashMap<ErasedArtifactHandle, ArtifactRequirement> = HashMap::new();
508        for &(a, req) in &self.artifacts {
509            merged
510                .entry(a)
511                .and_modify(|existing| {
512                    // required if any registration is required
513                    existing.optional = existing.optional && req.optional;
514                    // use LocalOnly if any registration requires it
515                    if matches!(req.remote, RemoteAccess::LocalOnly) {
516                        existing.remote = RemoteAccess::LocalOnly;
517                    }
518                })
519                .or_insert(req);
520        }
521
522        for (a, req) in merged {
523            let use_source = matches!(req.remote, RemoteAccess::Allow);
524
525            let result = if use_source {
526                resolver.resolve_source(a)
527            } else {
528                resolver.resolve(a).map(ArtifactSource::Local)
529            };
530
531            match result {
532                Ok(source) => {
533                    resolved.insert(a, source);
534                }
535                Err(_) if req.optional => {}
536                Err(e) => failed.push_str(&format!("{:?} - {:#}\n", a, e)),
537            }
538        }
539
540        if !failed.is_empty() {
541            anyhow::bail!("Artifact resolution failed:\n{}", failed);
542        }
543
544        Ok(TestArtifacts {
545            artifacts: Arc::new(resolved),
546        })
547    }
548}
549
550/// A resolved set of test artifacts, returned by
551/// [`TestArtifactRequirements::resolve`].
552#[derive(Clone)]
553pub struct TestArtifacts {
554    artifacts: Arc<HashMap<ErasedArtifactHandle, ArtifactSource>>,
555}
556
557impl TestArtifacts {
558    /// Try to get the resolved path of an artifact.
559    ///
560    /// Returns `None` if the artifact was not required. Panics if the artifact
561    /// is only available remotely.
562    #[track_caller]
563    pub fn try_get(&self, artifact: impl AsArtifactHandle) -> Option<&Path> {
564        self.artifacts.get(&artifact.erase()).map(|source| {
565            match source {
566                ArtifactSource::Local(p) => p.as_path(),
567                ArtifactSource::Remote { .. } => panic!(
568                    "Artifact {:?} is only available remotely; use require_source() or download it locally",
569                    artifact.erase()
570                ),
571            }
572        })
573    }
574
575    /// Get the resolved path of an artifact.
576    ///
577    /// Panics if the artifact was not required or is only available remotely.
578    #[track_caller]
579    pub fn get(&self, artifact: impl AsArtifactHandle) -> &Path {
580        self.try_get(artifact.erase())
581            .unwrap_or_else(|| panic!("Artifact not initially required: {:?}", artifact.erase()))
582    }
583
584    /// Try to get the resolved source of an artifact.
585    #[track_caller]
586    pub fn try_get_source(&self, artifact: impl AsArtifactHandle) -> Option<&ArtifactSource> {
587        self.artifacts.get(&artifact.erase())
588    }
589
590    /// Get the resolved source of an artifact.
591    #[track_caller]
592    pub fn get_source(&self, artifact: impl AsArtifactHandle) -> &ArtifactSource {
593        self.try_get_source(artifact.erase())
594            .unwrap_or_else(|| panic!("Artifact not initially required: {:?}", artifact.erase()))
595    }
596}