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