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/// A trait that marks a type as being the type-safe ID for a petri artifact.
24///
25/// This trait should never be implemented manually! It will be automatically
26/// implemented on the correct type when declaring artifacts using
27/// [`declare_artifacts!`](crate::declare_artifacts).
28pub trait ArtifactId: 'static {
29    /// A globally unique ID corresponding to this artifact.
30    #[doc(hidden)]
31    const GLOBAL_UNIQUE_ID: &'static str;
32
33    /// ...in case you decide to flaunt the trait-level docs regarding manually
34    /// implementing this trait.
35    #[doc(hidden)]
36    fn i_know_what_im_doing_with_this_manual_impl_instead_of_using_the_declare_artifacts_macro();
37}
38
39/// A type-safe handle to a particular Artifact, as declared using the
40/// [`declare_artifacts!`](crate::declare_artifacts) macro.
41#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
42pub struct ArtifactHandle<A: ArtifactId>(PhantomData<A>);
43
44impl<A: ArtifactId + std::fmt::Debug> std::fmt::Debug for ArtifactHandle<A> {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        std::fmt::Debug::fmt(&self.erase(), f)
47    }
48}
49
50/// A resolved artifact path for artifact `A`.
51pub struct ResolvedArtifact<A = ()>(Option<PathBuf>, PhantomData<A>);
52
53impl<A> Clone for ResolvedArtifact<A> {
54    fn clone(&self) -> Self {
55        Self(self.0.clone(), self.1)
56    }
57}
58
59impl<A> std::fmt::Debug for ResolvedArtifact<A> {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_tuple("ResolvedArtifact").field(&self.0).finish()
62    }
63}
64
65impl<A> ResolvedArtifact<A> {
66    /// Erases the type `A`.
67    pub fn erase(self) -> ResolvedArtifact {
68        ResolvedArtifact(self.0, PhantomData)
69    }
70
71    /// Gets the resolved path of the artifact.
72    #[track_caller]
73    pub fn get(&self) -> &Path {
74        self.0
75            .as_ref()
76            .expect("cannot get path in requirements phase")
77    }
78}
79
80impl<A> From<ResolvedArtifact<A>> for PathBuf {
81    #[track_caller]
82    fn from(ra: ResolvedArtifact<A>) -> PathBuf {
83        ra.0.expect("cannot get path in requirements phase")
84    }
85}
86
87impl<A> AsRef<Path> for ResolvedArtifact<A> {
88    #[track_caller]
89    fn as_ref(&self) -> &Path {
90        self.get()
91    }
92}
93
94impl<A> AsRef<OsStr> for ResolvedArtifact<A> {
95    #[track_caller]
96    fn as_ref(&self) -> &OsStr {
97        self.get().as_ref()
98    }
99}
100
101/// A resolve artifact path for an optional artifact `A`.
102#[derive(Clone, Debug)]
103pub struct ResolvedOptionalArtifact<A = ()>(OptionalArtifactState, PhantomData<A>);
104
105#[derive(Clone, Debug)]
106enum OptionalArtifactState {
107    Collecting,
108    Missing,
109    Present(PathBuf),
110}
111
112impl<A> ResolvedOptionalArtifact<A> {
113    /// Erases the type `A`.
114    pub fn erase(self) -> ResolvedOptionalArtifact {
115        ResolvedOptionalArtifact(self.0, PhantomData)
116    }
117
118    /// Gets the resolved path of the artifact, if it was found.
119    #[track_caller]
120    pub fn get(&self) -> Option<&Path> {
121        match self.0 {
122            OptionalArtifactState::Collecting => panic!("cannot get path in requirements phase"),
123            OptionalArtifactState::Missing => None,
124            OptionalArtifactState::Present(ref path) => Some(path),
125        }
126    }
127}
128
129/// An artifact resolver, used both to express requirements for artifacts and to
130/// resolve them to paths.
131pub struct ArtifactResolver<'a>(ArtifactResolverInner<'a>);
132
133impl<'a> ArtifactResolver<'a> {
134    /// Returns a resolver to collect requirements; the artifact objects returned by
135    /// [`require`](Self::require) will panic if used.
136    pub fn collector(requirements: &'a mut TestArtifactRequirements) -> Self {
137        ArtifactResolver(ArtifactResolverInner::Collecting(RefCell::new(
138            requirements,
139        )))
140    }
141
142    /// Returns a resolver to resolve artifacts.
143    pub fn resolver(artifacts: &'a TestArtifacts) -> Self {
144        ArtifactResolver(ArtifactResolverInner::Resolving(artifacts))
145    }
146
147    /// Resolve a required artifact.
148    pub fn require<A: ArtifactId>(&self, handle: ArtifactHandle<A>) -> ResolvedArtifact<A> {
149        match &self.0 {
150            ArtifactResolverInner::Collecting(requirements) => {
151                requirements.borrow_mut().require(handle.erase());
152                ResolvedArtifact(None, PhantomData)
153            }
154            ArtifactResolverInner::Resolving(artifacts) => {
155                ResolvedArtifact(Some(artifacts.get(handle).to_owned()), PhantomData)
156            }
157        }
158    }
159
160    /// Resolve an optional artifact.
161    pub fn try_require<A: ArtifactId>(
162        &self,
163        handle: ArtifactHandle<A>,
164    ) -> ResolvedOptionalArtifact<A> {
165        match &self.0 {
166            ArtifactResolverInner::Collecting(requirements) => {
167                requirements.borrow_mut().try_require(handle.erase());
168                ResolvedOptionalArtifact(OptionalArtifactState::Collecting, PhantomData)
169            }
170            ArtifactResolverInner::Resolving(artifacts) => ResolvedOptionalArtifact(
171                artifacts
172                    .try_get(handle)
173                    .map_or(OptionalArtifactState::Missing, |p| {
174                        OptionalArtifactState::Present(p.to_owned())
175                    }),
176                PhantomData,
177            ),
178        }
179    }
180}
181
182enum ArtifactResolverInner<'a> {
183    Collecting(RefCell<&'a mut TestArtifactRequirements>),
184    Resolving(&'a TestArtifacts),
185}
186
187/// A type-erased handle to a particular Artifact, with no information as to
188/// what exactly the artifact is.
189#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
190pub struct ErasedArtifactHandle {
191    artifact_id_str: &'static str,
192}
193
194impl std::fmt::Debug for ErasedArtifactHandle {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        // the `declare_artifacts!` macro uses `module_path!` under-the-hood to
197        // generate an artifact_id_str based on the artifact's crate + module
198        // path. To avoid collisions, the mod is named `TYPE_NAME__ty`, but to
199        // make it easier to parse output, we strip the `__ty`.
200        write!(
201            f,
202            "{}",
203            self.artifact_id_str
204                .strip_suffix("__ty")
205                .unwrap_or(self.artifact_id_str)
206        )
207    }
208}
209
210impl<A: ArtifactId> PartialEq<ErasedArtifactHandle> for ArtifactHandle<A> {
211    fn eq(&self, other: &ErasedArtifactHandle) -> bool {
212        &self.erase() == other
213    }
214}
215
216impl<A: ArtifactId> PartialEq<ArtifactHandle<A>> for ErasedArtifactHandle {
217    fn eq(&self, other: &ArtifactHandle<A>) -> bool {
218        self == &other.erase()
219    }
220}
221
222impl<A: ArtifactId> ArtifactHandle<A> {
223    /// Create a new typed artifact handle. It is unlikely you will need to call
224    /// this directly.
225    pub const fn new() -> Self {
226        Self(PhantomData)
227    }
228}
229
230/// Helper trait to allow uniform handling of both typed and untyped artifact
231/// handles in various contexts.
232pub trait AsArtifactHandle {
233    /// Return a type-erased handle to the given artifact.
234    fn erase(&self) -> ErasedArtifactHandle;
235}
236
237impl AsArtifactHandle for ErasedArtifactHandle {
238    fn erase(&self) -> ErasedArtifactHandle {
239        *self
240    }
241}
242
243impl<A: ArtifactId> AsArtifactHandle for ArtifactHandle<A> {
244    fn erase(&self) -> ErasedArtifactHandle {
245        ErasedArtifactHandle {
246            artifact_id_str: A::GLOBAL_UNIQUE_ID,
247        }
248    }
249}
250
251/// Declare one or more type-safe artifacts.
252#[macro_export]
253macro_rules! declare_artifacts {
254    (
255        $(
256            $(#[$doc:meta])*
257            $name:ident
258        ),*
259        $(,)?
260    ) => {
261        $(
262            $crate::paste::paste! {
263                $(#[$doc])*
264                #[expect(non_camel_case_types)]
265                pub const $name: $crate::ArtifactHandle<$name> = $crate::ArtifactHandle::new();
266
267                #[doc = concat!("Type-tag for [`",  stringify!($name), "`]")]
268                #[expect(non_camel_case_types)]
269                #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
270                pub enum $name {}
271
272                #[expect(non_snake_case)]
273                mod [< $name __ty >] {
274                    impl $crate::ArtifactId for super::$name {
275                        const GLOBAL_UNIQUE_ID: &'static str = module_path!();
276                        fn i_know_what_im_doing_with_this_manual_impl_instead_of_using_the_declare_artifacts_macro() {}
277                    }
278                }
279            }
280        )*
281    };
282}
283
284/// A trait to resolve artifacts to paths.
285///
286/// Test authors are expected to use the [`TestArtifactRequirements`] and
287/// [`TestArtifacts`] abstractions to interact with artifacts, and should not
288/// use this API directly.
289pub trait ResolveTestArtifact {
290    /// Given an artifact handle, return its corresponding PathBuf.
291    ///
292    /// This method must use type-erased handles, as using typed artifact
293    /// handles in this API would cause the trait to no longer be object-safe.
294    fn resolve(&self, id: ErasedArtifactHandle) -> anyhow::Result<PathBuf>;
295}
296
297impl<T: ResolveTestArtifact + ?Sized> ResolveTestArtifact for &T {
298    fn resolve(&self, id: ErasedArtifactHandle) -> anyhow::Result<PathBuf> {
299        (**self).resolve(id)
300    }
301}
302
303/// A set of dependencies required to run a test.
304#[derive(Clone)]
305pub struct TestArtifactRequirements {
306    artifacts: Vec<(ErasedArtifactHandle, bool)>,
307}
308
309impl TestArtifactRequirements {
310    /// Create an empty set of dependencies.
311    pub fn new() -> Self {
312        TestArtifactRequirements {
313            artifacts: Vec::new(),
314        }
315    }
316
317    /// Add a dependency to the set of required artifacts.
318    pub fn require(&mut self, dependency: impl AsArtifactHandle) -> &mut Self {
319        self.artifacts.push((dependency.erase(), false));
320        self
321    }
322
323    /// Add an optional dependency to the set of artifacts.
324    pub fn try_require(&mut self, dependency: impl AsArtifactHandle) -> &mut Self {
325        self.artifacts.push((dependency.erase(), true));
326        self
327    }
328
329    /// Returns the current list of required depencencies.
330    pub fn required_artifacts(&self) -> impl Iterator<Item = ErasedArtifactHandle> + '_ {
331        self.artifacts
332            .iter()
333            .filter_map(|&(a, optional)| (!optional).then_some(a))
334    }
335
336    /// Returns the current list of optional dependencies.
337    pub fn optional_artifacts(&self) -> impl Iterator<Item = ErasedArtifactHandle> + '_ {
338        self.artifacts
339            .iter()
340            .filter_map(|&(a, optional)| optional.then_some(a))
341    }
342
343    /// Resolve the set of dependencies.
344    pub fn resolve(&self, resolver: impl ResolveTestArtifact) -> anyhow::Result<TestArtifacts> {
345        let mut failed = String::new();
346        let mut resolved = HashMap::new();
347
348        for &(a, optional) in &self.artifacts {
349            match resolver.resolve(a) {
350                Ok(p) => {
351                    resolved.insert(a, p);
352                }
353                Err(_) if optional => {}
354                Err(e) => failed.push_str(&format!("{:?} - {:#}\n", a, e)),
355            }
356        }
357
358        if !failed.is_empty() {
359            anyhow::bail!("Artifact resolution failed:\n{}", failed);
360        }
361
362        Ok(TestArtifacts {
363            artifacts: Arc::new(resolved),
364        })
365    }
366}
367
368/// A resolved set of test artifacts, returned by
369/// [`TestArtifactRequirements::resolve`].
370#[derive(Clone)]
371pub struct TestArtifacts {
372    artifacts: Arc<HashMap<ErasedArtifactHandle, PathBuf>>,
373}
374
375impl TestArtifacts {
376    /// Try to get the resolved path of an artifact.
377    #[track_caller]
378    pub fn try_get(&self, artifact: impl AsArtifactHandle) -> Option<&Path> {
379        self.artifacts.get(&artifact.erase()).map(|p| p.as_ref())
380    }
381
382    /// Get the resolved path of an artifact.
383    #[track_caller]
384    pub fn get(&self, artifact: impl AsArtifactHandle) -> &Path {
385        self.try_get(artifact.erase())
386            .unwrap_or_else(|| panic!("Artifact not initially required: {:?}", artifact.erase()))
387    }
388}