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