1use 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}