google_fonts_sources/
font_source.rs

1//! font repository information
2
3use std::{
4    borrow::Cow,
5    path::{Path, PathBuf},
6};
7
8use crate::{error::LoadRepoError, Config, Metadata};
9
10/// Information about a font source in a git repository
11#[derive(
12    Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
13)]
14#[non_exhaustive]
15pub struct FontSource {
16    /// The repository's url
17    pub repo_url: String,
18    /// The commit, as stored in the metadata file.
19    rev: String,
20    /// The path to the config file for this font, relative to the repo root.
21    ///
22    /// (if it is an external config, it is relative to the git cache root.)
23    pub config: PathBuf,
24    /// If `true`, this config does not exist in the repo
25    ///
26    /// In this case the config actually lives in the google/fonts repository,
27    /// alongside the metadata file.
28    ///
29    /// External configs are treated as if they live at `$REPO/source/config.yaml`.
30    #[serde(default, skip_serializing_if = "is_false")]
31    config_is_external: bool,
32    /// If `true`, this is a private googlefonts repo.
33    ///
34    /// We don't discover these repos, but they can be specified in json and
35    /// we will load them. In this case, a valid oauth token must be specified
36    /// via the `GITHUB_TOKEN` environment variable.
37    #[serde(default, skip_serializing_if = "is_false")]
38    auth: bool,
39    /// if `true`, there are multiple sources in this repo with different git revs.
40    ///
41    /// In this case we will check this source out into its own directory, with
42    /// the sha appended (like 'repo_$SHA') to disambiguate.
43    ///
44    /// This field is set in `crate::discover_sources`, and only considers sources
45    /// in that list.
46    #[serde(default, skip_serializing_if = "is_false")]
47    pub(crate) has_rev_conflict: bool,
48}
49
50// a little helper used above
51fn is_false(b: &bool) -> bool {
52    !*b
53}
54
55impl FontSource {
56    /// Create a `FontSource` after some validation.
57    ///
58    /// Returns `None` if the url has some unexpected format, or if there are
59    /// no config files
60    pub(crate) fn new(repo_url: String, rev: String, config: PathBuf) -> Result<Self, String> {
61        if repo_name_and_org_from_url(&repo_url).is_none() {
62            log::warn!("unexpected repo url '{repo_url}'");
63            return Err(repo_url);
64        }
65        Ok(Self {
66            repo_url,
67            rev,
68            config,
69            auth: false,
70            has_rev_conflict: false,
71            config_is_external: false,
72        })
73    }
74
75    pub(crate) fn with_external_config(
76        metadata: Metadata,
77        external_config_path: &Path,
78    ) -> Result<Self, TryFromMetadataError> {
79        let repo_url = metadata
80            .repo_url
81            .ok_or(TryFromMetadataError::MissingField("repo_url"))?;
82        let commit = metadata
83            .commit
84            .ok_or(TryFromMetadataError::MissingField("commit"))?;
85
86        let mut result = Self::new(repo_url, commit, external_config_path.to_path_buf())
87            .map_err(TryFromMetadataError::UnfamiliarUrl)?;
88        result.config_is_external = true;
89        Ok(result)
90    }
91
92    /// just for testing: doesn't care if a URL is well formed/exists etc
93    #[cfg(test)]
94    pub(crate) fn for_test(url: &str, rev: &str, config: &str) -> Self {
95        Self {
96            repo_url: url.into(),
97            rev: rev.into(),
98            config: config.into(),
99            auth: false,
100            has_rev_conflict: false,
101            config_is_external: false,
102        }
103    }
104
105    /// The name of the user or org that the repository lives under.
106    ///
107    /// This is 'googlefonts' for the repo `https://github.com/googlefonts/google-fonts-sources`
108    pub fn repo_org(&self) -> &str {
109        // unwrap is safe because we validate at construction time
110        repo_name_and_org_from_url(&self.repo_url).unwrap().0
111    }
112
113    /// The name of the repository.
114    ///
115    /// This is everything after the trailing '/' in e.g. `https://github.com/PaoloBiagini/Joan`
116    pub fn repo_name(&self) -> &str {
117        repo_name_and_org_from_url(&self.repo_url).unwrap().1
118    }
119
120    /// The commit rev of the repository's main branch, at discovery time.
121    pub fn git_rev(&self) -> &str {
122        &self.rev
123    }
124
125    /// Given a root cache directory, return the local path this repo.
126    ///
127    /// This is in the format, `{cache_dir}/{repo_org}/{repo_name}`
128    pub fn repo_path(&self, cache_dir: &Path) -> PathBuf {
129        // unwrap is okay because we already know the url is well formed
130        self.repo_path_for_url(cache_dir).unwrap()
131    }
132
133    fn repo_path_for_url(&self, cache_dir: &Path) -> Option<PathBuf> {
134        let (org, name) = repo_name_and_org_from_url(&self.repo_url)?;
135        let mut path = cache_dir.join(org);
136        if self.has_rev_conflict {
137            path.push(format!(
138                "{name}_{}",
139                self.rev.get(..10).unwrap_or(self.rev.as_str())
140            ));
141        } else {
142            path.push(name);
143        }
144        Some(path)
145    }
146
147    /// Return the URL we'll use to fetch the repo, handling authentication.
148    fn repo_url_with_auth_token_if_needed(&self) -> Result<Cow<'_, str>, LoadRepoError> {
149        if self.auth {
150            let auth_token =
151                std::env::var("GITHUB_TOKEN").map_err(|_| LoadRepoError::MissingAuth)?;
152            let url_body = self
153                .repo_url
154                .trim_start_matches("https://")
155                .trim_start_matches("www.");
156            let add_dot_git = if self.repo_url.ends_with(".git") {
157                ""
158            } else {
159                ".git"
160            };
161
162            let auth_url = format!("https://{auth_token}:x-oauth-basic@{url_body}{add_dot_git}");
163            Ok(auth_url.into())
164        } else {
165            Ok(self.repo_url.as_str().into())
166        }
167    }
168
169    /// Attempt to checkout/update this repo to the provided `cache_dir`.
170    ///
171    /// The repo will be checked out to '{cache_dir}/{repo_org}/{repo_name}',
172    /// and HEAD will be set to the `self.git_rev()`.
173    ///
174    /// Returns the path to the checkout on success.
175    ///
176    /// Returns an error if the repo cannot be cloned, the git rev cannot be
177    /// found, or if there is an io error.
178    pub fn instantiate(&self, cache_dir: &Path) -> Result<PathBuf, LoadRepoError> {
179        let font_dir = self.repo_path(cache_dir);
180
181        if font_dir.exists() && !font_dir.join(".git").exists() {
182            log::debug!("{} exists but is not a repo, removing", font_dir.display());
183            if let Err(e) = std::fs::remove_dir(&font_dir) {
184                // we don't want to remove a non-empty directory, just in case
185                log::warn!("could not remove {}: '{e}'", font_dir.display());
186            }
187        }
188
189        if !font_dir.exists() {
190            std::fs::create_dir_all(&font_dir)?;
191            let repo_url = self.repo_url_with_auth_token_if_needed()?;
192            log::info!("cloning {repo_url}");
193            super::clone_repo(&repo_url, &font_dir)?;
194        }
195
196        if !super::checkout_rev(&font_dir, &self.rev)? {
197            return Err(LoadRepoError::NoCommit {
198                sha: self.rev.clone(),
199            });
200        }
201        Ok(font_dir)
202    }
203
204    /// An 'external' config is one that does not exist in the source repository.
205    ///
206    /// Instead it lives in the google/fonts repository, alongside the metadata
207    /// file for this family.
208    ///
209    /// The caller must figure out how to handle this. The actual config path can
210    /// be retrieved using the [`config_path`][Self::config_path] method here.
211    pub fn config_is_external(&self) -> bool {
212        self.config_is_external
213    }
214
215    /// Return path to the config file for this repo, if it exists.
216    ///
217    /// Returns an error if the repo cannot be cloned, or if no config files
218    /// are found.
219    pub fn config_path(&self, cache_dir: &Path) -> Result<PathBuf, LoadRepoError> {
220        let base_dir = if self.config_is_external() {
221            cache_dir.to_owned()
222        } else {
223            self.instantiate(cache_dir)?
224        };
225        let config_path = base_dir.join(&self.config);
226        if !config_path.exists() {
227            Err(LoadRepoError::NoConfig)
228        } else {
229            Ok(config_path)
230        }
231    }
232
233    /// Return a `Vec` of source files in this respository.
234    ///
235    /// If necessary, this will create a new checkout of this repo at
236    /// '{git_cache_dir}/{repo_org}/{repo_name}'.
237    pub fn get_sources(&self, git_cache_dir: &Path) -> Result<Vec<PathBuf>, LoadRepoError> {
238        let font_dir = self.instantiate(git_cache_dir)?;
239        let config_path = font_dir.join(&self.config);
240        let config = Config::load(&config_path)?;
241        let mut sources = config
242            .sources
243            .iter()
244            .filter_map(|source| {
245                let source = config_path.parent().unwrap_or(&font_dir).join(source);
246                source.exists().then_some(source)
247            })
248            .collect::<Vec<_>>();
249        sources.sort_unstable();
250        sources.dedup();
251
252        Ok(sources)
253    }
254}
255
256fn repo_name_and_org_from_url(url: &str) -> Option<(&str, &str)> {
257    let url = url.trim_end_matches('/');
258    let (rest, name) = url.rsplit_once('/')?;
259    let (_, org) = rest.rsplit_once('/')?;
260    Some((org, name))
261}
262
263#[derive(Clone, Debug, thiserror::Error)]
264pub enum TryFromMetadataError {
265    #[error("missing field '{0}'")]
266    MissingField(&'static str),
267    #[error("unfamiliar URL '{0}'")]
268    UnfamiliarUrl(String),
269}
270
271/// The impl does not account for possible external config files.
272impl TryFrom<Metadata> for FontSource {
273    type Error = TryFromMetadataError;
274
275    fn try_from(meta: Metadata) -> Result<Self, Self::Error> {
276        if let Some(badurl) = meta.unknown_repo_url() {
277            return Err(TryFromMetadataError::UnfamiliarUrl(badurl.to_owned()));
278        }
279        FontSource::new(
280            meta.repo_url
281                .ok_or(TryFromMetadataError::MissingField("repo_url"))?,
282            meta.commit
283                .ok_or(TryFromMetadataError::MissingField("commit"))?,
284            meta.config_yaml
285                .ok_or(TryFromMetadataError::MissingField("config_yaml"))?
286                .into(),
287        )
288        .map_err(TryFromMetadataError::UnfamiliarUrl)
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn org_and_name_from_url() {
298        assert_eq!(
299            repo_name_and_org_from_url("https://github.com/hyper-type/hahmlet/"),
300            Some(("hyper-type", "hahmlet")),
301        );
302        assert_eq!(
303            repo_name_and_org_from_url("https://github.com/hyper-type/Advent"),
304            Some(("hyper-type", "Advent")),
305        );
306    }
307
308    #[test]
309    fn test_non_sources_config() {
310        let source = FontSource::for_test(
311            "https://github.com/danhhong/Nokora",
312            "9c5f991b700b9be3519315a854a7b986e6877ace",
313            "Source/builder.yaml",
314        );
315        let temp_dir = tempfile::tempdir().unwrap();
316        let sources = source
317            .get_sources(temp_dir.path())
318            .expect("should be able to get sources");
319        assert_eq!(sources.len(), 1);
320        assert_eq!(
321            sources[0],
322            temp_dir.path().join("danhhong/Nokora/Source/Nokora.glyphs")
323        );
324    }
325}