1use std::{
4 borrow::Cow,
5 path::{Path, PathBuf},
6};
7
8use crate::{error::LoadRepoError, Config, Metadata};
9
10#[derive(
12 Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
13)]
14#[non_exhaustive]
15pub struct FontSource {
16 pub repo_url: String,
18 rev: String,
20 pub config: PathBuf,
24 #[serde(default, skip_serializing_if = "is_false")]
31 config_is_external: bool,
32 #[serde(default, skip_serializing_if = "is_false")]
38 auth: bool,
39 #[serde(default, skip_serializing_if = "is_false")]
47 pub(crate) has_rev_conflict: bool,
48}
49
50fn is_false(b: &bool) -> bool {
52 !*b
53}
54
55impl FontSource {
56 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 #[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 pub fn repo_org(&self) -> &str {
109 repo_name_and_org_from_url(&self.repo_url).unwrap().0
111 }
112
113 pub fn repo_name(&self) -> &str {
117 repo_name_and_org_from_url(&self.repo_url).unwrap().1
118 }
119
120 pub fn git_rev(&self) -> &str {
122 &self.rev
123 }
124
125 pub fn repo_path(&self, cache_dir: &Path) -> PathBuf {
129 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 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 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 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 pub fn config_is_external(&self) -> bool {
212 self.config_is_external
213 }
214
215 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 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
271impl 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}