Skip to main content

pulith_source/
lib.rs

1//! Composable source abstractions and planning for Pulith.
2
3use std::fmt;
4use std::path::PathBuf;
5use std::str::FromStr;
6
7use pulith_resource::{RequestedResource, ResolvedResource, ResourceLocator, ValidUrl};
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11pub type Result<T> = std::result::Result<T, SourceError>;
12
13#[derive(Debug, Error, Clone, PartialEq, Eq)]
14pub enum SourceError {
15    #[error("source set must not be empty")]
16    EmptySourceSet,
17    #[error("mirror set must not be empty")]
18    EmptyMirrorSet,
19    #[error("path must not be empty")]
20    EmptyPath,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct HttpAssetSource {
25    pub url: ValidUrl,
26    pub file_name: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct SourcePath(String);
31
32impl SourcePath {
33    pub fn new(value: impl Into<String>) -> Result<Self> {
34        let value = value.into();
35        ensure_non_empty_string(&value, SourceError::EmptyPath)?;
36        Ok(Self(value))
37    }
38}
39
40impl fmt::Display for SourcePath {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(&self.0)
43    }
44}
45
46impl FromStr for SourcePath {
47    type Err = SourceError;
48
49    fn from_str(s: &str) -> Result<Self> {
50        Self::new(s)
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct MirrorSource {
56    pub mirrors: Vec<ValidUrl>,
57    pub path: SourcePath,
58}
59
60impl MirrorSource {
61    pub fn new(mirrors: Vec<ValidUrl>, path: impl Into<String>) -> Result<Self> {
62        ensure_non_empty_slice(&mirrors, SourceError::EmptyMirrorSet)?;
63        Ok(Self {
64            mirrors,
65            path: SourcePath::new(path)?,
66        })
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct LocalSource {
72    pub path: PathBuf,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct GitSource {
77    pub url: ValidUrl,
78    pub rev: Option<String>,
79    pub subpath: Option<PathBuf>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub enum RemoteSource {
84    HttpAsset(HttpAssetSource),
85    Mirror(MirrorSource),
86    Git(GitSource),
87}
88
89impl RemoteSource {
90    pub fn resolved_candidates(&self) -> Vec<ResolvedSourceCandidate> {
91        match self {
92            Self::HttpAsset(source) => vec![ResolvedSourceCandidate::Url(source.url.clone())],
93            Self::Mirror(source) => source
94                .mirrors
95                .iter()
96                .map(|base| {
97                    let joined = base
98                        .as_url()
99                        .join(&source.path.to_string())
100                        .expect("validated mirror path");
101                    ResolvedSourceCandidate::Url(
102                        ValidUrl::parse(joined.as_str()).expect("joined mirror URL"),
103                    )
104                })
105                .collect(),
106            Self::Git(source) => vec![ResolvedSourceCandidate::Git {
107                url: source.url.clone(),
108                rev: source.rev.clone(),
109                subpath: source.subpath.clone(),
110            }],
111        }
112    }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub enum SourceDefinition {
117    Remote(RemoteSource),
118    Local(LocalSource),
119}
120
121impl SourceDefinition {
122    pub fn resolved_candidates(&self) -> Vec<ResolvedSourceCandidate> {
123        match self {
124            Self::Remote(remote) => remote.resolved_candidates(),
125            Self::Local(source) => vec![ResolvedSourceCandidate::LocalPath(source.path.clone())],
126        }
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub enum SelectionStrategy {
132    OrderedFallback,
133    Race,
134    Exhaustive,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct SourceSet {
139    entries: Vec<SourceDefinition>,
140}
141
142impl SourceSet {
143    pub fn new(entries: Vec<SourceDefinition>) -> Result<Self> {
144        ensure_non_empty_slice(&entries, SourceError::EmptySourceSet)?;
145        Ok(Self { entries })
146    }
147
148    pub fn entries(&self) -> &[SourceDefinition] {
149        &self.entries
150    }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct Unplanned;
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct Planned {
158    strategy: SelectionStrategy,
159    candidates: Vec<ResolvedSourceCandidate>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct SourcePlan<S> {
164    set: SourceSet,
165    state: S,
166}
167
168pub type SourceSpec = SourcePlan<Unplanned>;
169pub type PlannedSources = SourcePlan<Planned>;
170
171impl SourceSpec {
172    pub fn new(set: SourceSet) -> Self {
173        Self {
174            set,
175            state: Unplanned,
176        }
177    }
178
179    pub fn from_locator(locator: &ResourceLocator) -> Result<Self> {
180        Ok(Self::new(source_set_from_locator(locator)?))
181    }
182
183    pub fn from_requested_resource(resource: &RequestedResource) -> Result<Self> {
184        Self::from_locator(&resource.spec().locator)
185    }
186
187    pub fn from_resolved_resource(resource: &ResolvedResource) -> Result<Self> {
188        Self::from_locator(&resource.spec().locator)
189    }
190
191    pub fn plan(self, strategy: SelectionStrategy) -> PlannedSources {
192        planned_sources(self.set, strategy)
193    }
194
195    pub fn into_planned(self, strategy: SelectionStrategy) -> PlannedSources {
196        self.plan(strategy)
197    }
198}
199
200impl<S> SourcePlan<S> {
201    pub fn set(&self) -> &SourceSet {
202        &self.set
203    }
204}
205
206impl PlannedSources {
207    pub fn from_locator(locator: &ResourceLocator, strategy: SelectionStrategy) -> Result<Self> {
208        Ok(planned_sources(source_set_from_locator(locator)?, strategy))
209    }
210
211    pub fn from_requested_resource(
212        resource: &RequestedResource,
213        strategy: SelectionStrategy,
214    ) -> Result<Self> {
215        Ok(SourceSpec::from_requested_resource(resource)?.plan(strategy))
216    }
217
218    pub fn from_resolved_resource(
219        resource: &ResolvedResource,
220        strategy: SelectionStrategy,
221    ) -> Result<Self> {
222        Ok(SourceSpec::from_resolved_resource(resource)?.plan(strategy))
223    }
224
225    pub fn strategy(&self) -> &SelectionStrategy {
226        &self.state.strategy
227    }
228
229    pub fn candidates(&self) -> &[ResolvedSourceCandidate] {
230        &self.state.candidates
231    }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub enum ResolvedSourceCandidate {
236    Url(ValidUrl),
237    LocalPath(PathBuf),
238    Git {
239        url: ValidUrl,
240        rev: Option<String>,
241        subpath: Option<PathBuf>,
242    },
243}
244
245impl ResolvedSourceCandidate {
246    fn from_definition(definition: &SourceDefinition) -> Vec<Self> {
247        definition.resolved_candidates()
248    }
249}
250
251fn source_set_from_locator(locator: &ResourceLocator) -> Result<SourceSet> {
252    match locator {
253        ResourceLocator::Url(url) => SourceSet::new(vec![http_asset(url.clone())]),
254        ResourceLocator::Alternatives(urls) => {
255            SourceSet::new(urls.iter().cloned().map(http_asset).collect())
256        }
257        ResourceLocator::LocalPath(path) => {
258            SourceSet::new(vec![SourceDefinition::Local(LocalSource {
259                path: path.clone(),
260            })])
261        }
262    }
263}
264
265fn planned_sources(set: SourceSet, strategy: SelectionStrategy) -> PlannedSources {
266    let candidates = set
267        .entries
268        .iter()
269        .flat_map(ResolvedSourceCandidate::from_definition)
270        .collect();
271
272    SourcePlan {
273        set,
274        state: Planned {
275            strategy,
276            candidates,
277        },
278    }
279}
280
281fn http_asset(url: ValidUrl) -> SourceDefinition {
282    SourceDefinition::Remote(RemoteSource::HttpAsset(HttpAssetSource {
283        url,
284        file_name: None,
285    }))
286}
287
288fn ensure_non_empty_slice<T>(values: &[T], error: SourceError) -> Result<()> {
289    if values.is_empty() {
290        Err(error)
291    } else {
292        Ok(())
293    }
294}
295
296fn ensure_non_empty_string(value: &str, error: SourceError) -> Result<()> {
297    if value.is_empty() { Err(error) } else { Ok(()) }
298}
299
300pub trait SourceAdapter {
301    fn expand(
302        &self,
303        resource: &ResolvedResource,
304        definition: &SourceDefinition,
305    ) -> Result<SourceSet>;
306}
307
308#[derive(Debug, Default, Clone, Copy)]
309pub struct PassthroughAdapter;
310
311impl SourceAdapter for PassthroughAdapter {
312    fn expand(
313        &self,
314        _resource: &ResolvedResource,
315        definition: &SourceDefinition,
316    ) -> Result<SourceSet> {
317        SourceSet::new(vec![definition.clone()])
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use pulith_resource::{
325        RequestedResource, ResolvedLocator, ResolvedVersion, ResourceId, ResourceSpec,
326    };
327
328    #[test]
329    fn source_spec_can_be_built_from_locator() {
330        let locator = ResourceLocator::Alternatives(vec![
331            ValidUrl::parse("https://a.example.com/file.zip").unwrap(),
332            ValidUrl::parse("https://b.example.com/file.zip").unwrap(),
333        ]);
334
335        let spec = SourceSpec::from_locator(&locator).unwrap();
336        let planned = spec.plan(SelectionStrategy::OrderedFallback);
337        assert_eq!(planned.candidates().len(), 2);
338    }
339
340    #[test]
341    fn source_spec_can_be_built_from_requested_resource() {
342        let requested = RequestedResource::new(ResourceSpec::new(
343            ResourceId::parse("example/runtime").unwrap(),
344            ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.zip").unwrap()),
345        ));
346
347        let planned = SourceSpec::from_requested_resource(&requested)
348            .unwrap()
349            .plan(SelectionStrategy::OrderedFallback);
350
351        assert_eq!(planned.candidates().len(), 1);
352    }
353
354    #[test]
355    fn planned_sources_can_be_built_from_requested_resource() {
356        let requested = RequestedResource::new(ResourceSpec::new(
357            ResourceId::parse("example/runtime").unwrap(),
358            ResourceLocator::Url(ValidUrl::parse("https://example.com/runtime.zip").unwrap()),
359        ));
360
361        let planned =
362            PlannedSources::from_requested_resource(&requested, SelectionStrategy::OrderedFallback)
363                .unwrap();
364
365        assert_eq!(planned.candidates().len(), 1);
366        assert_eq!(planned.strategy(), &SelectionStrategy::OrderedFallback);
367    }
368
369    #[test]
370    fn source_spec_can_be_built_from_resolved_resource() {
371        let resolved = RequestedResource::new(ResourceSpec::new(
372            ResourceId::parse("example/runtime").unwrap(),
373            ResourceLocator::LocalPath(PathBuf::from("/tmp/runtime.bin")),
374        ))
375        .resolve(
376            ResolvedVersion::new("1.0.0").unwrap(),
377            ResolvedLocator::LocalPath(PathBuf::from("/tmp/runtime.bin")),
378            None,
379        );
380
381        let planned = SourceSpec::from_resolved_resource(&resolved)
382            .unwrap()
383            .plan(SelectionStrategy::OrderedFallback);
384
385        assert_eq!(planned.candidates().len(), 1);
386    }
387
388    #[test]
389    fn planned_sources_can_be_built_from_resolved_resource() {
390        let resolved = RequestedResource::new(ResourceSpec::new(
391            ResourceId::parse("example/runtime").unwrap(),
392            ResourceLocator::LocalPath(PathBuf::from("/tmp/runtime.bin")),
393        ))
394        .resolve(
395            ResolvedVersion::new("1.0.0").unwrap(),
396            ResolvedLocator::LocalPath(PathBuf::from("/tmp/runtime.bin")),
397            None,
398        );
399
400        let planned =
401            PlannedSources::from_resolved_resource(&resolved, SelectionStrategy::OrderedFallback)
402                .unwrap();
403
404        assert_eq!(planned.candidates().len(), 1);
405        assert_eq!(planned.strategy(), &SelectionStrategy::OrderedFallback);
406    }
407
408    #[test]
409    fn mirror_source_expands_to_urls() {
410        let mirrors = vec![
411            ValidUrl::parse("https://mirror-a.example.com/").unwrap(),
412            ValidUrl::parse("https://mirror-b.example.com/").unwrap(),
413        ];
414        let set = SourceSet::new(vec![SourceDefinition::Remote(RemoteSource::Mirror(
415            MirrorSource::new(mirrors, "downloads/tool.tar.gz").unwrap(),
416        ))])
417        .unwrap();
418        let planned = SourceSpec::new(set).plan(SelectionStrategy::Race);
419        assert_eq!(planned.candidates().len(), 2);
420    }
421
422    #[test]
423    fn adapter_expands_source_for_resource() {
424        let resource = RequestedResource::new(ResourceSpec::new(
425            ResourceId::parse("example/tool").unwrap(),
426            ResourceLocator::Url(ValidUrl::parse("https://example.com/tool.zip").unwrap()),
427        ))
428        .resolve(
429            ResolvedVersion::new("1.0.0").unwrap(),
430            ResolvedLocator::Url(ValidUrl::parse("https://example.com/tool.zip").unwrap()),
431            None,
432        );
433
434        let definition = SourceDefinition::Remote(RemoteSource::HttpAsset(HttpAssetSource {
435            url: ValidUrl::parse("https://example.com/tool.zip").unwrap(),
436            file_name: Some("tool.zip".to_string()),
437        }));
438
439        let expanded = PassthroughAdapter.expand(&resource, &definition).unwrap();
440        assert_eq!(expanded.entries().len(), 1);
441    }
442}