1use crate::{
5 error::{FMLError, Result},
6 SUPPORT_URL_LOADING,
7};
8
9use anyhow::anyhow;
10use std::{
11 collections::{hash_map::DefaultHasher, BTreeMap},
12 env,
13 fmt::Display,
14 hash::{Hash, Hasher},
15 path::{Path, PathBuf},
16};
17use url::Url;
18
19pub(crate) const GITHUB_USER_CONTENT_DOTCOM: &str = "https://raw.githubusercontent.com";
20pub(crate) const API_GITHUB_DOTCOM: &str = "https://api.github.com";
21
22#[derive(Clone)]
23pub struct LoaderConfig {
24 pub cwd: PathBuf,
25 pub repo_files: Vec<String>,
26 pub cache_dir: Option<PathBuf>,
27 pub refs: BTreeMap<String, String>,
28}
29
30impl LoaderConfig {
31 pub(crate) fn repo_and_path(f: &str) -> Option<(String, String)> {
32 if f.starts_with('@') {
33 let parts = f.splitn(3, '/').collect::<Vec<&str>>();
34 match parts.as_slice() {
35 [user, repo, path] => Some((format!("{user}/{repo}"), path.to_string())),
36 _ => None,
37 }
38 } else {
39 None
40 }
41 }
42}
43
44impl Default for LoaderConfig {
45 fn default() -> Self {
46 Self {
47 repo_files: Default::default(),
48 cache_dir: None,
49 cwd: env::current_dir().expect("Current Working Directory is not set"),
50 refs: Default::default(),
51 }
52 }
53}
54
55#[derive(Clone, Debug)]
57pub struct GitHubRepoFilePath {
58 repo_id: String,
60
61 git_ref: String,
63
64 url: Url,
72}
73
74impl GitHubRepoFilePath {
75 pub fn new(repo_id: &str, git_ref: &str) -> Self {
76 Self {
77 repo_id: repo_id.into(),
78 git_ref: git_ref.into(),
79 url: Url::parse("invalid://do-not-use/").expect("This is a constant, valid URL"),
80 }
81 }
82
83 pub fn repo_id(&self) -> &str {
85 &self.repo_id
86 }
87
88 pub fn git_ref(&self) -> &str {
90 &self.git_ref
91 }
92
93 pub fn path(&self) -> &str {
95 self.url.path()
96 }
97
98 pub fn join(&self, file: &str) -> Result<Self> {
99 Ok(Self {
100 repo_id: self.repo_id.clone(),
101 git_ref: self.git_ref.clone(),
102 url: self.url.join(file)?,
103 })
104 }
105
106 pub(crate) fn default_download_url_as_str(&self) -> String {
112 format!(
113 "{}/{}/{}{}",
114 GITHUB_USER_CONTENT_DOTCOM,
115 self.repo_id,
116 self.git_ref,
117 self.path() )
119 }
120
121 pub fn default_download_url(&self) -> Result<Url> {
128 Url::parse(&self.default_download_url_as_str()).map_err(Into::into)
129 }
130
131 pub fn contents_api_url(&self) -> Result<Url> {
132 Url::parse(&format!(
134 "{}/repos/{}/contents{}?ref={}",
135 API_GITHUB_DOTCOM,
136 self.repo_id,
137 self.path(), self.git_ref
139 ))
140 .map_err(Into::into)
141 }
142}
143
144#[derive(Clone, Debug)]
146pub enum FilePath {
147 Local(PathBuf),
148 Remote(Url),
149 GitHub(GitHubRepoFilePath),
150}
151
152impl FilePath {
153 pub fn new(cwd: &Path, file: &str) -> Result<Self> {
154 Ok(if file.contains("://") {
155 FilePath::Remote(Url::parse(file)?)
156 } else {
157 FilePath::Local(cwd.join(file))
158 })
159 }
160
161 pub fn join(&self, file: &str) -> Result<Self> {
165 if file.contains("://") {
166 return Ok(FilePath::Remote(Url::parse(file)?));
167 }
168 Ok(match self {
169 Self::Local(p) => Self::Local(
170 if is_dir(p) {
174 p.join(file)
175 } else {
176 p.parent()
177 .expect("a file within a parent directory")
178 .join(file)
179 },
180 ),
181 Self::Remote(u) => Self::Remote(u.join(file)?),
182 Self::GitHub(p) => Self::GitHub(p.join(file)?),
183 })
184 }
185
186 pub fn canonicalize(&self) -> Result<Self> {
187 Ok(match self {
188 Self::Local(p) => Self::Local(p.canonicalize().map_err(|e| {
189 FMLError::InvalidPath(format!("{}: {}", e, p.as_path().display()))
192 })?),
193 _ => self.clone(),
194 })
195 }
196
197 pub fn extension(&self) -> Option<&str> {
198 Some(match self {
199 Self::Local(p) => {
200 let ext = p.extension()?;
201 ext.to_str()?
202 }
203 Self::GitHub(GitHubRepoFilePath { url, .. }) | Self::Remote(url) => {
204 let file = url.path_segments()?.next_back()?;
205 let (_, ext) = file.rsplit_once('.')?;
206 ext
207 }
208 })
209 }
210}
211
212impl Display for FilePath {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 match self {
215 Self::Local(p) => p.display().fmt(f),
216 Self::Remote(u) => u.fmt(f),
217 Self::GitHub(p) => p.default_download_url_as_str().fmt(f),
218 }
219 }
220}
221
222impl From<&Path> for FilePath {
223 fn from(path: &Path) -> Self {
224 Self::Local(path.into())
225 }
226}
227
228#[cfg(not(test))]
229fn is_dir(path_buf: &Path) -> bool {
230 path_buf.is_dir()
231}
232
233#[cfg(test)]
239fn is_dir(path_buf: &Path) -> bool {
240 path_buf.display().to_string().ends_with('/')
241}
242
243static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
244
245#[derive(Clone, Debug)]
263pub struct FileLoader {
264 cache_dir: Option<PathBuf>,
265
266 repo_refs: BTreeMap<String, FilePath>,
269
270 cwd: PathBuf,
273}
274
275impl TryFrom<&LoaderConfig> for FileLoader {
276 type Error = FMLError;
277
278 fn try_from(loader_config: &LoaderConfig) -> Result<Self, Self::Error> {
279 let cache_dir = loader_config.cache_dir.clone();
280 let cwd = loader_config.cwd.clone();
281
282 let mut file_loader = Self::new(cwd, cache_dir, Default::default())?;
283
284 for (repo_id, git_ref) in &loader_config.refs {
285 file_loader.add_repo(repo_id, git_ref)?;
286 }
287
288 for f in &loader_config.repo_files {
289 let path = file_loader.file_path(f)?;
290 file_loader.add_repo_file(&path)?;
291 }
292
293 Ok(file_loader)
294 }
295}
296
297impl FileLoader {
298 pub fn new(
299 cwd: PathBuf,
300 cache_dir: Option<PathBuf>,
301 repo_refs: BTreeMap<String, FilePath>,
302 ) -> Result<Self> {
303 Ok(Self {
304 cache_dir,
305 cwd,
306 repo_refs,
307 })
308 }
309
310 #[allow(clippy::should_implement_trait)]
311 #[cfg(test)]
312 pub fn default() -> Result<Self> {
313 let cwd = std::env::current_dir()?;
314 let cache_path = cwd.join("build/app/fml-cache");
315 Self::new(
316 std::env::current_dir().expect("Current Working Directory not set"),
317 Some(cache_path),
318 Default::default(),
319 )
320 }
321
322 pub fn add_repo_file(&mut self, file: &FilePath) -> Result<()> {
334 let config: BTreeMap<String, String> = self.read(file)?;
335
336 for (k, v) in config {
337 self.add_repo_relative(file, &k, &v)?;
338 }
339
340 Ok(())
341 }
342
343 pub fn add_repo(&mut self, repo_id: &str, loc: &str) -> Result<()> {
351 self.add_repo_relative(&FilePath::Local(self.cwd.clone()), repo_id, loc)
352 }
353
354 fn add_repo_relative(&mut self, cwd: &FilePath, repo_id: &str, loc: &str) -> Result<()> {
355 let repo_id = repo_id.strip_prefix('@').unwrap_or(repo_id);
361
362 let file_path = if loc.starts_with('.')
365 || loc.starts_with('/')
366 || loc.contains(":\\")
367 || loc.contains("://")
368 {
369 let loc = if loc.ends_with('/') {
373 loc.to_string()
374 } else {
375 format!("{}/", loc)
376 };
377
378 cwd.join(&loc)?
380 } else {
381 self.remote_file_path(repo_id, loc)
383 };
384
385 self.repo_refs.insert(repo_id.into(), file_path);
387 Ok(())
388 }
389
390 fn remote_file_path(&self, repo: &str, branch_or_tag: &str) -> FilePath {
391 FilePath::GitHub(GitHubRepoFilePath::new(repo, branch_or_tag))
392 }
393
394 fn default_remote_path(&self, key: String) -> FilePath {
395 self.remote_file_path(&key, "main")
396 }
397
398 pub fn read_to_string(&self, file: &FilePath) -> Result<String> {
405 Ok(match file {
406 FilePath::Local(path) => std::fs::read_to_string(path)?,
407 FilePath::Remote(url) => self.fetch_and_cache(url)?,
408 FilePath::GitHub(p) => {
409 let api_key = match env::var("GITHUB_BEARER_TOKEN") {
413 Ok(api_key) => Some(api_key),
414 Err(env::VarError::NotPresent) => None,
415 Err(env::VarError::NotUnicode(_)) => Err(FMLError::InvalidApiToken)?,
416 };
417
418 let download_url = if let Some(api_key) = api_key {
419 let contents_api_url = p.contents_api_url()?;
420
421 viaduct::Request::get(contents_api_url)
424 .header("Authorization", format!("Bearer {api_key}"))?
425 .header("User-Agent", USER_AGENT)?
426 .send()?
427 .require_success()?
428 .json::<serde_json::Value>()?
429 .get("download_url")
430 .and_then(serde_json::Value::as_str)
431 .ok_or_else(|| {
432 anyhow!(
433 "GitHub API did not return a download_url for @{}/{} at ref {}",
434 p.repo_id(),
435 p.path(),
436 p.git_ref()
437 )
438 })
439 .and_then(|u| Url::parse(u).map_err(Into::into))?
440 } else {
441 p.default_download_url()?
442 };
443
444 self.fetch_and_cache(&download_url)?
445 }
446 })
447 }
448
449 pub fn read<T: serde::de::DeserializeOwned>(&self, file: &FilePath) -> Result<T> {
450 let string = self
451 .read_to_string(file)
452 .map_err(|e| FMLError::InvalidPath(format!("{file}: {e}")))?;
453
454 Ok(serde_yaml::from_str(&string)?)
455 }
456
457 fn fetch_and_cache(&self, url: &Url) -> Result<String> {
458 if !SUPPORT_URL_LOADING {
459 unimplemented!("Loading manifests from URLs is not yet supported ({})", url);
460 }
461 let path_buf = self.create_cache_path_buf(url);
462 Ok(if path_buf.exists() {
463 std::fs::read_to_string(path_buf)?
464 } else {
465 let res = viaduct::Request::get(url.clone())
466 .header("User-Agent", USER_AGENT)?
467 .send()?
468 .require_success()?;
469 let text = String::from_utf8(res.body)?;
470
471 let parent = path_buf.parent().expect("Cache directory is specified");
472 if !parent.exists() {
473 std::fs::create_dir_all(parent)?;
474 }
475
476 std::fs::write(path_buf, &text)?;
477 text
478 })
479 }
480
481 fn create_cache_path_buf(&self, url: &Url) -> PathBuf {
482 let mut hasher = DefaultHasher::new();
486 url.hash(&mut hasher);
487 let checksum = hasher.finish();
488 let filename = match url.path_segments() {
489 Some(mut segments) => segments.next_back().unwrap_or("unknown.txt"),
490 None => "unknown.txt",
491 };
492 let filename = format!("{:x}_{}", (checksum & 0x000000000000FFFF) as u16, filename,);
495
496 self.cache_dir().join(filename)
497 }
498
499 fn cache_dir(&self) -> &Path {
500 match &self.cache_dir {
501 Some(d) => d,
502 _ => self.tmp_cache_dir(),
503 }
504 }
505
506 fn tmp_cache_dir<'a>(&self) -> &'a Path {
507 use std::time::SystemTime;
508 lazy_static::lazy_static! {
509 static ref CACHE_DIR_NAME: String = format!("nimbus-fml-manifests-{:x}", match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
510 Ok(n) => n.as_micros() & 0x00ffffff,
511 Err(_) => 0,
512 });
513
514 static ref TMP_CACHE_DIR: PathBuf = std::env::temp_dir().join(CACHE_DIR_NAME.as_str());
515 }
516 &TMP_CACHE_DIR
517 }
518
519 pub fn join(&self, base: &FilePath, f: &str) -> Result<FilePath> {
528 Ok(if let Some(u) = self.resolve_url_shortcut(f)? {
529 u
530 } else {
531 base.join(f)?
532 })
533 }
534
535 pub fn file_path(&self, f: &str) -> Result<FilePath> {
544 Ok(if let Some(u) = self.resolve_url_shortcut(f)? {
545 u
546 } else {
547 FilePath::new(&self.cwd, f)?
548 })
549 }
550
551 fn resolve_url_shortcut(&self, f: &str) -> Result<Option<FilePath>> {
556 if f.starts_with('@') {
557 let f = f.replacen('@', "", 1);
558 let parts = f.splitn(3, '/').collect::<Vec<&str>>();
559 match parts.as_slice() {
560 [user, repo, path] => {
561 let key = format!("{}/{}", user, repo);
562 Ok(if let Some(repo) = self.lookup_repo_path(user, repo) {
563 Some(repo.join(path)?)
564 } else {
565 let repo = self.default_remote_path(key);
566 Some(repo.join(path)?)
567 })
568 }
569 _ => Err(FMLError::InvalidPath(format!(
570 "'{}' needs to include a username, a repo and a filepath",
571 f
572 ))),
573 }
574 } else {
575 Ok(None)
576 }
577 }
578
579 fn lookup_repo_path(&self, user: &str, repo: &str) -> Option<&FilePath> {
580 let key = format!("{}/{}", user, repo);
581 self.repo_refs.get(&key)
582 }
583}
584
585impl Drop for FileLoader {
586 fn drop(&mut self) {
587 if self.cache_dir.is_some() {
588 return;
589 }
590 let cache_dir = self.tmp_cache_dir();
591 if cache_dir.exists() {
592 _ = std::fs::remove_dir_all(cache_dir);
593 }
594 }
595}
596
597#[cfg(test)]
598mod unit_tests {
599 use std::fs;
600
601 use crate::util::{build_dir, pkg_dir};
602
603 use super::*;
604
605 #[test]
606 fn test_relative_paths() -> Result<()> {
607 let tmp = std::env::temp_dir();
608
609 let file = tmp.join("foo/bar.txt");
610 let obs = FilePath::from(file.as_path());
611
612 assert!(matches!(obs, FilePath::Local(_)));
613 assert!(obs.to_string().ends_with("foo/bar.txt"));
614
615 let obs = obs.join("baz.txt")?;
616 assert!(obs.to_string().ends_with("foo/baz.txt"));
617
618 let obs = obs.join("./bam.txt")?;
619 assert!(obs.to_string().ends_with("foo/./bam.txt"));
627
628 let obs = obs.join("https://example.com/foo/bar.txt")?;
629 assert!(matches!(obs, FilePath::Remote(_)));
630 assert_eq!(obs.to_string(), "https://example.com/foo/bar.txt");
631
632 let obs = obs.join("baz.txt")?;
633 assert_eq!(obs.to_string(), "https://example.com/foo/baz.txt");
634
635 let obs = obs.join("./bam.txt")?;
636 assert_eq!(obs.to_string(), "https://example.com/foo/bam.txt");
637
638 let obs = obs.join("../brum/bram.txt")?;
639 assert_eq!(obs.to_string(), "https://example.com/brum/bram.txt");
640
641 Ok(())
642 }
643
644 #[test]
645 fn test_at_shorthand_with_no_at() -> Result<()> {
646 let files = create_loader()?;
647 let cwd = FilePath::Local(files.cwd.clone());
648 let src_file = cwd.join("base/old.txt")?;
649
650 let obs = files.join(&src_file, "a/file.txt")?;
652 assert!(matches!(obs, FilePath::Local(_)));
653 assert_eq!(
654 obs.to_string(),
655 format!("{}/base/a/file.txt", remove_trailing_slash(&cwd))
656 );
657 Ok(())
658 }
659
660 #[test]
661 fn test_at_shorthand_default_branch() -> Result<()> {
662 let files = create_loader()?;
663 let cwd = FilePath::Local(files.cwd.clone());
664 let src_file = cwd.join("base/old.txt")?;
665
666 let obs = files.join(&src_file, "@repo/unspecified/a/file.txt")?;
669 assert!(
670 matches!(obs, FilePath::GitHub(ref gh) if gh.repo_id() == "repo/unspecified" && gh.git_ref() == "main" && gh.path() == "/a/file.txt")
671 );
672 assert_eq!(
673 obs.to_string(),
674 "https://raw.githubusercontent.com/repo/unspecified/main/a/file.txt"
675 );
676 Ok(())
677 }
678
679 #[test]
680 fn test_at_shorthand_absolute_url() -> Result<()> {
681 let mut files = create_loader()?;
682 let cwd = FilePath::Local(files.cwd.clone());
683 let src_file = cwd.join("base/old.txt")?;
684
685 files.add_repo("@repos/url", "https://example.com/remote/directory/path")?;
688
689 let obs = files.join(&src_file, "@repos/url/a/file.txt")?;
690 assert!(matches!(obs, FilePath::Remote(_)));
691 assert_eq!(
692 obs.to_string(),
693 "https://example.com/remote/directory/path/a/file.txt"
694 );
695
696 let obs = files.file_path("@repos/url/b/file.txt")?;
697 assert!(matches!(obs, FilePath::Remote(_)));
698 assert_eq!(
699 obs.to_string(),
700 "https://example.com/remote/directory/path/b/file.txt"
701 );
702 Ok(())
703 }
704
705 #[test]
706 fn test_at_shorthand_specified_branch() -> Result<()> {
707 let mut files = create_loader()?;
708 let cwd = FilePath::Local(files.cwd.clone());
709 let src_file = cwd.join("base/old.txt")?;
710
711 files.add_repo("@repos/branch", "develop")?;
714 let obs = files.join(&src_file, "@repos/branch/a/file.txt")?;
715 assert!(
716 matches!(obs, FilePath::GitHub(ref gh) if gh.repo_id() == "repos/branch" && gh.git_ref() == "develop" && gh.path() == "/a/file.txt")
717 );
718 assert_eq!(
719 obs.to_string(),
720 "https://raw.githubusercontent.com/repos/branch/develop/a/file.txt"
721 );
722
723 let obs = files.file_path("@repos/branch/b/file.txt")?;
724 assert!(
725 matches!(obs, FilePath::GitHub(ref gh) if gh.repo_id() == "repos/branch" && gh.git_ref() == "develop" && gh.path() == "/b/file.txt")
726 );
727 assert_eq!(
728 obs.to_string(),
729 "https://raw.githubusercontent.com/repos/branch/develop/b/file.txt"
730 );
731 Ok(())
732 }
733
734 #[test]
735 fn test_at_shorthand_local_development() -> Result<()> {
736 let mut files = create_loader()?;
737 let cwd = FilePath::Local(files.cwd.clone());
738 let src_file = cwd.join("base/old.txt")?;
739
740 let rel_dir = "../directory/path";
743 files.add_repo("@repos/local", rel_dir)?;
744
745 let obs = files.join(&src_file, "@repos/local/a/file.txt")?;
746 assert!(matches!(obs, FilePath::Local(_)));
747 assert_eq!(
748 obs.to_string(),
749 format!("{}/{}/a/file.txt", remove_trailing_slash(&cwd), rel_dir)
750 );
751
752 let obs = files.file_path("@repos/local/b/file.txt")?;
753 assert!(matches!(obs, FilePath::Local(_)));
754 assert_eq!(
755 obs.to_string(),
756 format!("{}/{}/b/file.txt", remove_trailing_slash(&cwd), rel_dir)
757 );
758
759 Ok(())
760 }
761
762 fn create_loader() -> Result<FileLoader, FMLError> {
763 let cache_dir = PathBuf::from(format!("{}/cache", build_dir()));
764 let repo_refs = Default::default();
765 let cwd = PathBuf::from(format!("{}/fixtures/", pkg_dir()));
766 let loader = FileLoader::new(cwd, Some(cache_dir), repo_refs)?;
767 Ok(loader)
768 }
769
770 #[test]
771 fn test_at_shorthand_from_config_file() -> Result<()> {
772 let cwd = PathBuf::from(pkg_dir());
773
774 let config = &LoaderConfig {
775 cwd,
776 cache_dir: None,
777 repo_files: vec![
778 "fixtures/loaders/config_files/remote.json".to_string(),
779 "fixtures/loaders/config_files/local.yaml".to_string(),
780 ],
781 refs: Default::default(),
782 };
783
784 let files: FileLoader = config.try_into()?;
785 let cwd = FilePath::Local(files.cwd.clone());
786
787 let tfr = files.file_path("@my/remote/file.txt")?;
789 assert_eq!(
790 tfr.to_string(),
791 "https://example.com/repo/branch/file.txt".to_string()
792 );
793
794 let tf1 = files.file_path("@test/nested1/test-file.txt")?;
796 assert_eq!(
797 tf1.to_string(),
798 format!(
799 "{}/fixtures/loaders/config_files/./nested-1/test-file.txt",
800 &cwd
801 )
802 );
803
804 let tf2 = files.file_path("@test/nested2/test-file.txt")?;
806 assert_eq!(
807 tf2.to_string(),
808 format!(
809 "{}/fixtures/loaders/config_files/./nested-2/test-file.txt",
810 &cwd
811 )
812 );
813
814 let tf1 = files.read_to_string(&tf1)?;
815 let tf2 = files.read_to_string(&tf2)?;
816
817 assert_eq!("test-file/1".to_string(), tf1);
818 assert_eq!("test-file/2".to_string(), tf2);
819
820 Ok(())
821 }
822
823 fn remove_trailing_slash(cwd: &FilePath) -> String {
824 let s = cwd.to_string();
825 let mut chars = s.chars();
826 if s.ends_with('/') {
827 chars.next_back();
828 }
829 chars.as_str().to_string()
830 }
831
832 #[test]
833 fn test_at_shorthand_override_via_cli() -> Result<()> {
834 let cwd = PathBuf::from(pkg_dir());
835
836 let config = &LoaderConfig {
837 cwd,
838 cache_dir: None,
839 repo_files: Default::default(),
840 refs: BTreeMap::from([("@my-remote/repo".to_string(), "cli-branch".to_string())]),
841 };
842
843 let files: FileLoader = config.try_into()?;
844
845 let tfr = files.file_path("@my-remote/repo/path/to/file.txt")?;
847 assert_eq!(
848 tfr.to_string(),
849 "https://raw.githubusercontent.com/my-remote/repo/cli-branch/path/to/file.txt"
851 .to_string()
852 );
853
854 Ok(())
855 }
856
857 #[test]
858 fn test_dropping_tmp_cache_dir() -> Result<()> {
859 let cwd = PathBuf::from(pkg_dir());
860 let config = &LoaderConfig {
861 cwd,
862 cache_dir: None,
863 repo_files: Default::default(),
864 refs: Default::default(),
865 };
866
867 let files: FileLoader = config.try_into()?;
868 let cache_dir = files.tmp_cache_dir();
869 fs::create_dir_all(cache_dir)?;
870
871 assert!(cache_dir.exists());
872 drop(files);
873
874 assert!(!cache_dir.exists());
875 Ok(())
876 }
877
878 #[test]
879 fn test_github_repo_file_path() -> Result<()> {
880 let gh = GitHubRepoFilePath::new("owner/repo-name", "ref").join("a/file.txt")?;
881 assert_eq!(
882 gh.contents_api_url()?.to_string(),
883 "https://api.github.com/repos/owner/repo-name/contents/a/file.txt?ref=ref",
884 );
885 assert_eq!(
886 gh.default_download_url()?.to_string(),
887 "https://raw.githubusercontent.com/owner/repo-name/ref/a/file.txt"
888 );
889
890 let gh = gh.join("/b/file.txt")?;
891 assert_eq!(
892 gh.contents_api_url()?.to_string(),
893 "https://api.github.com/repos/owner/repo-name/contents/b/file.txt?ref=ref",
894 );
895 assert_eq!(
896 gh.default_download_url()?.to_string(),
897 "https://raw.githubusercontent.com/owner/repo-name/ref/b/file.txt"
898 );
899
900 let gh = gh.join("/c/")?.join("file.txt")?;
901 assert_eq!(
902 gh.contents_api_url()?.to_string(),
903 "https://api.github.com/repos/owner/repo-name/contents/c/file.txt?ref=ref",
904 );
905 assert_eq!(
906 gh.default_download_url()?.to_string(),
907 "https://raw.githubusercontent.com/owner/repo-name/ref/c/file.txt"
908 );
909
910 let gh = gh.join("d/")?.join("file.txt")?;
911 assert_eq!(
912 gh.contents_api_url()?.to_string(),
913 "https://api.github.com/repos/owner/repo-name/contents/c/d/file.txt?ref=ref",
914 );
915 assert_eq!(
916 gh.default_download_url()?.to_string(),
917 "https://raw.githubusercontent.com/owner/repo-name/ref/c/d/file.txt"
918 );
919
920 Ok(())
921 }
922
923 #[test]
924 fn test_extension() -> Result<()> {
925 let path = FilePath::Local("file.json".into());
926 assert_eq!(path.extension(), Some("json"));
927
928 let path = FilePath::Local("file.fml.yaml".into());
929 assert_eq!(path.extension(), Some("yaml"));
930
931 let path = FilePath::Local("file".into());
932 assert_eq!(path.extension(), None);
933
934 let path = FilePath::Remote("https://example.com/file.json".try_into()?);
936 assert_eq!(path.extension(), Some("json"));
937
938 let path = FilePath::Remote("https://example.com/file.fml.yaml".try_into()?);
939 assert_eq!(path.extension(), Some("yaml"));
940
941 let path = FilePath::Remote("https://example.com/".try_into()?);
942 assert_eq!(path.extension(), None);
943
944 let path = FilePath::Remote("https://example.com/file".try_into()?);
945 assert_eq!(path.extension(), None);
946
947 let path = FilePath::Remote("https://example.com/path/".try_into()?);
948 assert_eq!(path.extension(), None);
949
950 let path = FilePath::GitHub(GitHubRepoFilePath::new("example", "main"));
951 assert_eq!(path.extension(), None);
952
953 let path = path.join("./file.json")?;
954 assert_eq!(path.extension(), Some("json"));
955
956 let path = path.join("./file.fml.yaml")?;
957 assert_eq!(path.extension(), Some("yaml"));
958
959 Ok(())
960 }
961}