1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, anyhow};
4use itertools::Itertools;
5use tempfile::{TempDir, tempdir};
6use tokio::process::Command;
7use url::Url;
8
9use crate::{directory::subdirectories, git::UnderstandGitResult};
10
11const TEMPLATE_SOURCE_DIR: &str = "templates";
12const TEMPLATE_VERSION_TAG_PREFIX: &str = "spin/templates/v";
13
14#[derive(Debug)]
16pub enum TemplateSource {
17 Git(GitTemplateSource),
24 File(PathBuf),
29 RemoteTar(Url),
35}
36
37#[derive(Debug)]
39pub struct GitTemplateSource {
40 url: Url,
42 branch: Option<String>,
44 spin_version: String,
47}
48
49impl TemplateSource {
50 pub fn try_from_git(
53 git_url: impl AsRef<str>,
54 branch: &Option<String>,
55 spin_version: &str,
56 ) -> anyhow::Result<Self> {
57 let url_str = git_url.as_ref();
58 let url =
59 Url::parse(url_str).with_context(|| format!("Failed to parse {url_str} as URL"))?;
60 Ok(Self::Git(GitTemplateSource {
61 url,
62 branch: branch.clone(),
63 spin_version: spin_version.to_owned(),
64 }))
65 }
66
67 pub(crate) fn to_install_record(&self) -> Option<crate::reader::RawInstalledFrom> {
68 match self {
69 Self::Git(g) => Some(crate::reader::RawInstalledFrom::Git {
70 git: g.url.to_string(),
71 }),
72 Self::File(p) => {
73 if p.is_absolute() {
75 Some(crate::reader::RawInstalledFrom::File {
76 dir: format!("{}", p.display()),
77 })
78 } else {
79 None
80 }
81 }
82 Self::RemoteTar(url) => Some(crate::reader::RawInstalledFrom::RemoteTar {
83 url: url.to_string(),
84 }),
85 }
86 }
87
88 pub async fn resolved_tag(&self) -> Option<String> {
92 match self {
93 Self::Git(g) => version_matched_tag(g.url.as_str(), &g.spin_version).await,
94 _ => None,
95 }
96 }
97
98 pub(crate) fn as_git_url(&self) -> Option<&Url> {
99 match self {
100 TemplateSource::Git(git) => Some(&git.url),
101 _ => None,
102 }
103 }
104}
105
106pub(crate) struct LocalTemplateSource {
107 root: PathBuf,
108 _temp_dir: Option<TempDir>,
109}
110
111impl TemplateSource {
112 pub(crate) async fn get_local(&self) -> anyhow::Result<LocalTemplateSource> {
113 match self {
114 Self::Git(git_source) => clone_local(git_source).await,
115 Self::File(path) => check_local(path).await,
116 Self::RemoteTar(url) => download_untar_local(url).await,
117 }
118 }
119
120 pub(crate) fn requires_copy(&self) -> bool {
121 match self {
122 Self::Git { .. } => true,
123 Self::File(_) => false,
124 Self::RemoteTar(_) => true,
125 }
126 }
127}
128
129impl LocalTemplateSource {
130 pub async fn template_directories(&self) -> anyhow::Result<Vec<PathBuf>> {
131 let templates_root = self.root.join(TEMPLATE_SOURCE_DIR);
132 if templates_root.exists() {
133 subdirectories(&templates_root).with_context(|| {
134 format!("Failed to read contents of '{TEMPLATE_SOURCE_DIR}' directory")
135 })
136 } else {
137 Err(anyhow!(
138 "Template source {} does not contain a '{}' directory",
139 self.root.display(),
140 TEMPLATE_SOURCE_DIR
141 ))
142 }
143 }
144}
145
146async fn clone_local(git_source: &GitTemplateSource) -> anyhow::Result<LocalTemplateSource> {
147 let temp_dir = tempdir()?;
148 let path = temp_dir.path().to_owned();
149
150 let url_str = git_source.url.as_str();
151
152 let actual_branch = match &git_source.branch {
153 Some(b) => Some(b.clone()),
154 None => version_matched_tag(url_str, &git_source.spin_version).await,
155 };
156
157 let mut git = Command::new("git");
158 git.arg("clone");
159 git.arg("--depth").arg("1");
160
161 if let Some(b) = actual_branch {
162 git.arg("--branch").arg(b);
163 }
164
165 git.arg(url_str).arg(&path);
166
167 let clone_result = git.output().await.understand_git_result();
168 match clone_result {
169 Ok(_) => Ok(LocalTemplateSource {
170 root: path,
171 _temp_dir: Some(temp_dir),
172 }),
173 Err(e) => Err(anyhow!("Error cloning Git repo {}: {}", url_str, e)),
174 }
175}
176
177async fn version_matched_tag(url: &str, spin_version: &str) -> Option<String> {
178 let preferred_tag = version_preferred_tag(spin_version);
179
180 let mut git = Command::new("git");
181 git.arg("ls-remote");
182 git.arg("--exit-code");
183 git.arg(url);
184 git.arg(&preferred_tag);
185
186 match git.output().await.understand_git_result() {
187 Ok(_) => Some(preferred_tag),
188 Err(_) => None,
189 }
190}
191
192fn version_preferred_tag(text: &str) -> String {
193 let mm_version = match semver::Version::parse(text) {
194 Ok(version) => format!("{}.{}", version.major, version.minor),
195 Err(_) => text.to_owned(),
196 };
197 format!("{TEMPLATE_VERSION_TAG_PREFIX}{mm_version}")
198}
199
200async fn check_local(path: &Path) -> anyhow::Result<LocalTemplateSource> {
201 if path.exists() {
202 Ok(LocalTemplateSource {
203 root: path.to_owned(),
204 _temp_dir: None,
205 })
206 } else {
207 Err(anyhow!("Path not found: {}", path.display()))
208 }
209}
210
211async fn download_untar_local(url: &Url) -> anyhow::Result<LocalTemplateSource> {
213 use bytes::buf::Buf;
214
215 let temp_dir = tempdir()?;
216 let path = temp_dir.path().to_owned();
217
218 let resp = reqwest::get(url.clone())
219 .await
220 .with_context(|| format!("Failed to download from {url}"))?;
221 let tar_content = resp
222 .bytes()
223 .await
224 .with_context(|| format!("Failed to download from {url}"))?;
225
226 let reader = flate2::read::GzDecoder::new(tar_content.reader());
227 let mut archive = tar::Archive::new(reader);
228 archive
229 .unpack(&path)
230 .context("Failed to unpack tar archive")?;
231
232 let templates_root = bypass_gh_added_root(path);
233
234 Ok(LocalTemplateSource {
235 root: templates_root,
236 _temp_dir: Some(temp_dir),
237 })
238}
239
240fn bypass_gh_added_root(unpack_dir: PathBuf) -> PathBuf {
243 if has_templates_dir(&unpack_dir) {
245 return unpack_dir;
246 }
247
248 let Ok(dirs) = unpack_dir.read_dir() else {
249 return unpack_dir;
252 };
253
254 let Ok(dir) = dirs.filter_map(Result::ok).exactly_one() else {
258 return unpack_dir;
259 };
260
261 let candidate_repo_root = dir.path();
262 let Ok(mut candidate_repo_dirs) = candidate_repo_root.read_dir() else {
263 return unpack_dir;
265 };
266 let has_templates_dir = candidate_repo_dirs.any(is_templates_dir);
267
268 if has_templates_dir {
269 candidate_repo_root
270 } else {
271 unpack_dir
272 }
273}
274
275fn has_templates_dir(path: &Path) -> bool {
276 let Ok(mut dirs) = path.read_dir() else {
277 return false;
278 };
279
280 dirs.any(is_templates_dir)
281}
282
283fn is_templates_dir(dir_entry: Result<std::fs::DirEntry, std::io::Error>) -> bool {
284 dir_entry.is_ok_and(|d| d.file_name() == TEMPLATE_SOURCE_DIR)
285}
286
287#[cfg(test)]
288mod test {
289 use super::*;
290
291 #[test]
292 fn preferred_tag_excludes_patch_version() {
293 assert_eq!("spin/templates/v1.2", version_preferred_tag("1.2.3"));
294 }
295
296 #[test]
297 fn preferred_tag_excludes_prerelease_and_build() {
298 assert_eq!(
299 "spin/templates/v1.2",
300 version_preferred_tag("1.2.3-preview.1")
301 );
302 assert_eq!(
303 "spin/templates/v1.2",
304 version_preferred_tag("1.2.3+build.0f74628")
305 );
306 assert_eq!(
307 "spin/templates/v1.2",
308 version_preferred_tag("1.2.3-alpha+0f74628")
309 );
310 }
311
312 #[test]
313 fn preferred_tag_defaults_sensibly_on_bad_semver() {
314 assert_eq!("spin/templates/v1.2", version_preferred_tag("1.2"));
315 assert_eq!("spin/templates/v1.2.3.4", version_preferred_tag("1.2.3.4"));
316 assert_eq!("spin/templates/vgarbage", version_preferred_tag("garbage"));
317 }
318}