1use std::collections::BTreeMap;
2use std::fs;
3use std::io::{ErrorKind, Write};
4use std::path::Path;
5use std::process::{Command, ExitStatus, Stdio};
6
7use regex::Regex;
8use serde::Deserialize;
9use walkdir::WalkDir;
10
11use crate::git_util;
12
13const ARCHETYPE_FILE: &str = "archetype.json";
14const ARCHETYPES_DIR: &str = "archetypes";
15const ARCHETYPE_REPO: &str = "https://github.com/codebase-rs/archetypes.git";
16const TEMPLATE_DIR: &str = "template";
17const CONFIG_REGEX: &str = r"\{\{[A-Z-_]+}}";
18
19#[derive(Deserialize, Clone)]
21pub struct Archetype {
22 pub description: String,
24 pub author: String,
26 pub executable: Option<String>,
28 pub arguments: Option<Vec<String>>,
30 pub default_config: BTreeMap<String, String>,
32}
33
34pub fn find<P: AsRef<Path>>(name: &str, config_dir: P) -> anyhow::Result<Option<Archetype>> {
40 let archetypes = archetypes(config_dir)?;
41 Ok(archetypes
42 .iter()
43 .find(|(archetype_name, _)| *archetype_name == &name.to_string())
44 .map(|(_, archetype)| archetype.clone()))
45}
46
47pub fn archetypes<P: AsRef<Path>>(config_dir: P) -> anyhow::Result<BTreeMap<String, Archetype>> {
52 let archetypes_dir = config_dir.as_ref().join(ARCHETYPES_DIR);
54
55 if !archetypes_dir.exists() {
56 git2::Repository::clone(ARCHETYPE_REPO, &archetypes_dir)?;
57 } else {
58 let repo = git2::Repository::open(&archetypes_dir)?;
59 git_util::pull(&repo, "origin", "master")?;
60 }
61
62 let mut archetypes: BTreeMap<String, Archetype> = BTreeMap::new();
63
64 for entry in WalkDir::new(&archetypes_dir)
65 .into_iter()
66 .filter_map(Result::ok)
67 .filter(|e| e.file_name().to_str().unwrap() == ARCHETYPE_FILE)
68 {
69 let archetypes_dir = archetypes_dir.to_str().unwrap();
71 let local_path: String = entry
72 .path()
73 .to_str()
74 .unwrap()
75 .replace(&format!("{}/", archetypes_dir), "")
76 .replace(&format!("/{}", ARCHETYPE_FILE), "");
77 let archetype_id = local_path;
78
79 let json = fs::read_to_string(&entry.path()).unwrap();
81 let json: Archetype = serde_json::from_str(&json).unwrap();
82
83 archetypes.insert(archetype_id, json);
84 }
85
86 Ok(archetypes)
87}
88
89pub fn execute<A: AsRef<Path>, B: AsRef<Path>>(
97 archetype_id: &str,
98 config: &BTreeMap<String, String>,
99 directory: A,
100 config_dir: B,
101) -> anyhow::Result<()> {
102 let archetype = find(&archetype_id, &config_dir)?.ok_or_else(|| {
103 anyhow::anyhow!(format!("No archetype with name `{}` found", archetype_id))
104 })?;
105
106 if archetype.executable.is_some() {
108 execute_wrapper(&archetype, config, directory).map(|_| ())
109 } else {
110 execute_template(archetype_id, &archetype, config, directory, &config_dir)
111 }
112}
113
114fn execute_wrapper<P: AsRef<Path>>(
121 archetype: &Archetype,
122 config: &BTreeMap<String, String>,
123 directory: P,
124) -> anyhow::Result<ExitStatus> {
125 let arguments = build_arguments(archetype, config)?;
126
127 Command::new(&archetype.executable.as_ref().unwrap())
128 .args(arguments)
129 .current_dir(directory)
130 .stdout(Stdio::null())
131 .stderr(Stdio::null())
132 .status()
133 .map_err(|e| {
134 if let ErrorKind::NotFound = e.kind() {
135 anyhow::anyhow!(format!(
136 "Missing executable '{}'",
137 archetype.executable.as_ref().unwrap()
138 ))
139 } else {
140 anyhow::Error::new(e)
141 }
142 })
143}
144
145fn execute_template<A: AsRef<Path>, B: AsRef<Path>>(
154 archetype_id: &str,
155 archetype: &Archetype,
156 config: &BTreeMap<String, String>,
157 directory: A,
158 config_dir: B,
159) -> anyhow::Result<()> {
160 let project_name = config.get("NAME").ok_or_else(|| anyhow::anyhow!(""))?;
161 let project_directory = directory.as_ref().join(project_name);
162
163 let template_dir = config_dir
164 .as_ref()
165 .join(ARCHETYPES_DIR)
166 .join(archetype_id)
167 .join(TEMPLATE_DIR);
168
169 if !template_dir.exists() {
170 return Err(anyhow::anyhow!(format!(
171 "Template directory {} does not exist",
172 template_dir.display()
173 )));
174 }
175
176 for entry in WalkDir::new(&template_dir)
178 .into_iter()
179 .filter_map(Result::ok)
180 {
181 let path: &Path = entry.path();
182 let local_path = path.strip_prefix(&template_dir)?;
183 let project_path = project_directory.join(local_path);
184
185 if entry.file_type().is_dir() {
187 fs::create_dir_all(&project_path)?;
188 continue;
189 }
190
191 let content = fs::read_to_string(path)?;
193 let content = process_template(&content, &archetype, &config)?;
194
195 let mut file = fs::File::create(project_path)?;
197 file.write_all(content.as_bytes())?;
198 }
199
200 Ok(())
201}
202
203fn build_arguments(
209 archetype: &Archetype,
210 config: &BTreeMap<String, String>,
211) -> anyhow::Result<Vec<String>> {
212 let mut arguments = Vec::new();
213
214 if archetype.arguments.is_none() {
216 return Ok(arguments);
217 }
218
219 let config_regex = Regex::new(CONFIG_REGEX)?;
220 for arg in archetype.arguments.as_ref().unwrap() {
221 if config_regex.is_match(&arg) {
222 let config_name = arg.replace("{{", "").replace("}}", "");
224 let config_value = config
225 .get(&config_name)
226 .or_else(|| archetype.default_config.get(&config_name))
227 .ok_or_else(|| anyhow::anyhow!("Missing archetype config '{}'", config_name))?;
228 arguments.push(config_value.clone());
229 } else {
230 arguments.push(arg.clone());
232 }
233 }
234
235 Ok(arguments)
236}
237
238fn process_template(
246 content: &str,
247 archetype: &Archetype,
248 config: &BTreeMap<String, String>,
249) -> anyhow::Result<String> {
250 let mut result = content.to_string();
251 let config_regex = Regex::new(CONFIG_REGEX)?;
252
253 for variable in config_regex.find_iter(&content) {
254 let config_name = variable.as_str().replace("{{", "").replace("}}", "");
255 let config_value = config
256 .get(&config_name)
257 .or_else(|| archetype.default_config.get(&config_name))
258 .ok_or_else(|| anyhow::anyhow!("Missing archetype config '{}'", config_name))?;
259
260 result = result.replace(variable.as_str(), config_value);
261 }
262
263 Ok(result)
264}
265
266#[cfg(test)]
267mod tests {
268 use std::collections::BTreeMap;
269
270 use crate::archetype::{build_arguments, process_template, Archetype};
271
272 #[test]
273 fn test_build_arguments() {
274 let mut default_config: BTreeMap<String, String> = BTreeMap::new();
275 default_config.insert("YEAR".to_string(), "2018".to_string());
276
277 let archetype = Archetype {
278 description: "".to_string(),
279 author: "Aloïs Micard <alois@micard.lu>".to_string(),
280 executable: Some("cargo".to_string()),
281 arguments: Some(vec![
282 "new".to_string(),
283 "{{NAME}}".to_string(),
284 "--bin".to_string(),
285 "--edition".to_string(),
286 "{{YEAR}}".to_string(),
287 ]),
288 default_config,
289 };
290
291 let mut config = BTreeMap::new();
292 let result = build_arguments(&archetype, &config);
293 assert!(result.is_err()); config.insert("NAME".to_string(), "test".to_string());
296 let result = build_arguments(&archetype, &config);
297 assert!(result.is_ok());
298 assert_eq!(
299 result.unwrap(),
300 vec!["new", "test", "--bin", "--edition", "2018"]
301 );
302 }
303
304 #[test]
305 fn test_process_template() {
306 let mut default_config: BTreeMap<String, String> = BTreeMap::new();
307 default_config.insert("FAV_DRINK".to_string(), "beer".to_string());
308
309 let archetype = Archetype {
310 description: "".to_string(),
311 author: "".to_string(),
312 executable: None,
313 arguments: None,
314 default_config,
315 };
316
317 let content = "Hello, my name is {{NAME}} and I like drinking {{FAV_DRINK}}!";
318
319 let mut config: BTreeMap<String, String> = BTreeMap::new();
320 let result = process_template(&content, &archetype, &config);
321 assert!(result.is_err());
322
323 config.insert("NAME".to_string(), "Aloïs Micard".to_string());
324 let result = process_template(&content, &archetype, &config);
325 assert!(result.is_ok());
326 assert_eq!(
327 result.unwrap(),
328 "Hello, my name is Aloïs Micard and I like drinking beer!"
329 )
330 }
331}