1use std::{
4 fs, io, mem,
5 path::{Path, PathBuf},
6};
7
8use clap::*;
9use color_print::cstr;
10use convert_case::{Case, Casing};
11
12use crate::util;
13
14#[derive(Args, Debug)]
15pub struct NewArgs {
16 #[arg(num_args(0..))]
26 value: Vec<String>,
27
28 #[arg(short, long, default_value = "zng-ui/zng-template")]
35 template: String,
36
37 #[arg(short, long, num_args(0..))]
45 set: Vec<String>,
46
47 #[arg(short, long, action)]
49 keys: bool,
50}
51
52pub fn run(args: NewArgs) {
53 let template = parse_template(args.template);
54
55 if args.keys {
56 return print_keys(template);
57 }
58
59 let arg_keys = match parse_key_values(args.value, args.set) {
60 Ok(arg_keys) => {
61 if arg_keys.is_empty() || (!arg_keys[0].0.is_empty() && arg_keys.iter().all(|(k, _)| k != "app")) {
62 fatal!("missing required key `app`")
63 }
64 arg_keys
65 }
66 Err(e) => fatal!("{e}"),
67 };
68
69 let app = &arg_keys[0].1;
71 let project_name = util::clean_value(app, true)
72 .unwrap_or_else(|e| fatal!("{e}"))
73 .replace(' ', "-")
74 .to_lowercase();
75 if let Err(e) = util::cmd("cargo new --quiet --bin", &[project_name.as_str()], &[]) {
76 let _ = std::fs::remove_dir_all(&project_name);
77 fatal!("cannot init project folder, {e}");
78 }
79
80 if let Err(e) = cleanup_cargo_new(&project_name) {
81 fatal!("failed to cleanup `cargo new` template, {e}");
82 }
83
84 let template_temp = PathBuf::from(format!("{project_name}.zng_template.tmp"));
86
87 let fatal_cleanup = || {
88 let _ = fs::remove_dir_all(&template_temp);
89 let _ = fs::remove_dir_all(&project_name);
90 };
91
92 let (template_keys, ignore) = template.git_clone(&template_temp, false).unwrap_or_else(|e| {
93 fatal_cleanup();
94 fatal!("failed to clone template, {e}")
95 });
96
97 let cx = Context::new(&template_temp, template_keys, arg_keys, ignore).unwrap_or_else(|e| {
98 fatal_cleanup();
99 fatal!("cannot parse template, {e}")
100 });
101 if let Err(e) = apply_template(&cx, &project_name) {
103 error!("cannot generate, {e}");
104 fatal_cleanup();
105 util::exit();
106 }
107
108 if Path::new(&project_name).join("Cargo.toml").exists() {
110 if let Err(e) = std::env::set_current_dir(project_name) {
111 fatal!("cannot format generated project, {e}")
112 }
113 crate::fmt::run(crate::fmt::FmtArgs::default());
114 }
115}
116
117fn parse_key_values(value: Vec<String>, define: Vec<String>) -> io::Result<ArgsKeyMap> {
118 let mut r = Vec::with_capacity(value.len() + define.len());
119
120 for value in value {
121 r.push((String::new(), value));
122 }
123
124 for key_value in define {
125 if let Some((key, value)) = key_value.trim_matches('"').split_once('=') {
126 if !is_key(key) {
127 return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("invalid key `{key}`")));
128 }
129 r.push((key.to_owned(), value.to_owned()));
130 }
131 }
132
133 Ok(r)
134}
135
136fn print_keys(template: Template) {
137 for i in 0..100 {
138 let template_temp = std::env::temp_dir().join(format!("cargo-zng-template-keys-help-{i}"));
139 if template_temp.exists() {
140 continue;
141 }
142
143 match template.git_clone(&template_temp, true) {
144 Ok((keys, _)) => {
145 println!("TEMPLATE KEYS\n");
146 for kv in keys {
147 let value = match &kv.value {
148 Some(dft) => dft.as_str(),
149 None => cstr!("<bold><y>required</y></bold>"),
150 };
151 println!(cstr!("<bold>{}=</bold>{}"), kv.key, value);
152 if !kv.docs.is_empty() {
153 for line in kv.docs.lines() {
154 println!(" {line}");
155 }
156 println!();
157 }
158 }
159 }
160 Err(e) => {
161 error!("failed to clone template, {e}");
162 }
163 }
164 let _ = fs::remove_dir_all(&template_temp);
165 return;
166 }
167 fatal!("failed to clone template, no temp dir available");
168}
169
170fn parse_template(arg: String) -> Template {
171 let (arg, branch) = arg.rsplit_once('#').unwrap_or((&arg, ""));
172
173 if arg.ends_with(".git") {
174 return Template::Git(arg.to_owned(), branch.to_owned());
175 }
176
177 if arg.starts_with("./") {
178 return Template::Local(PathBuf::from(arg), branch.to_owned());
179 }
180
181 if let Some((owner, repo)) = arg.split_once('/') {
182 if !owner.is_empty() && !repo.is_empty() && !repo.contains('/') {
183 return Template::Git(format!("https://github.com/{owner}/{repo}.git"), branch.to_owned());
184 }
185 }
186
187 let path = PathBuf::from(arg);
188 if path.is_absolute() {
189 return Template::Local(path.to_owned(), branch.to_owned());
190 }
191
192 fatal!("--template must be a `.git` URL, `owner/repo`, `./local` or `/absolute/local`");
193}
194
195enum Template {
196 Git(String, String),
197 Local(PathBuf, String),
198}
199impl Template {
200 fn git_clone(self, to: &Path, include_docs: bool) -> io::Result<(KeyMap, Vec<glob::Pattern>)> {
202 let (from, branch) = match self {
203 Template::Git(url, b) => (url, b),
204 Template::Local(path, b) => {
205 let path = dunce::canonicalize(path)?;
206 (path.display().to_string(), b)
207 }
208 };
209 let to_str = to.display().to_string();
210 let mut args = vec![from.as_str(), &to_str];
211 if !branch.is_empty() {
212 args.push("--branch");
213 args.push(&branch);
214 }
215 util::cmd_silent("git clone --depth 1", &args, &[])?;
216
217 let keys = match fs::read_to_string(to.join(".zng-template/keys")) {
218 Ok(s) => parse_keys(s, include_docs)?,
219 Err(e) => {
220 if e.kind() == io::ErrorKind::NotFound {
221 return Err(io::Error::new(
222 io::ErrorKind::NotFound,
223 "git repo is not a zng template, missing `.zng-template/keys`",
224 ));
225 }
226 return Err(e);
227 }
228 };
229
230 let mut ignore = vec![];
231 match fs::read_to_string(to.join(".zng-template/ignore")) {
232 Ok(i) => {
233 for glob in i.lines().map(|l| l.trim()).filter(|l| !l.is_empty() && !l.starts_with('#')) {
234 let glob = glob::Pattern::new(glob).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
235 ignore.push(glob);
236 }
237 }
238 Err(e) => {
239 if e.kind() != io::ErrorKind::NotFound {
240 return Err(e);
241 }
242 }
243 }
244
245 Ok((keys, ignore))
246 }
247}
248
249fn cleanup_cargo_new(path: &str) -> io::Result<()> {
250 for entry in fs::read_dir(path)? {
251 let path = entry?.path();
252 if path.components().any(|c| c.as_os_str() == ".git") {
253 continue;
254 }
255 if path.is_dir() {
256 fs::remove_dir_all(path)?;
257 } else if path.is_file() {
258 fs::remove_file(path)?;
259 }
260 }
261 Ok(())
262}
263
264fn apply_template(cx: &Context, package_name: &str) -> io::Result<()> {
265 let template_temp = &cx.template_root;
266
267 fs::remove_dir_all(template_temp.join(".git"))?;
269
270 let post = template_temp.join(".zng-template/post");
272 if post.is_dir() {
273 let post_replaced = template_temp.join(".zng-template/post-temp");
274 fs::create_dir_all(&post_replaced)?;
275 apply(cx, true, &post, &post_replaced)?;
276 fs::remove_dir_all(&post)?;
277 fs::rename(&post_replaced, &post)?;
278
279 unsafe {
280 std::env::set_var("ZNG_TEMPLATE_POST_DIR", &post);
282 }
283 }
284
285 let to = PathBuf::from(package_name);
287 apply(cx, false, template_temp, &to)?;
288
289 let bash = post.join("post.sh");
290 if bash.is_file() {
291 let script = fs::read_to_string(bash)?;
292 crate::res::built_in::sh_run(script, false, Some(&to))?;
293 } else {
294 let manifest = post.join("Cargo.toml");
295 if manifest.exists() {
296 let s = std::process::Command::new("cargo")
297 .arg("run")
298 .arg("--quiet")
299 .arg("--manifest-path")
300 .arg(manifest)
301 .current_dir(to)
302 .status()?;
303 if !s.success() {}
304 } else if post.exists() {
305 return Err(io::Error::new(
306 io::ErrorKind::InvalidData,
307 ".zng-template/post does not contain 'post.sh' nor 'Cargo.toml'",
308 ));
309 }
310 }
311
312 fs::remove_dir_all(template_temp)
314}
315
316fn apply(cx: &Context, is_post: bool, from: &Path, to: &Path) -> io::Result<()> {
317 for entry in walkdir::WalkDir::new(from).min_depth(1).max_depth(1).sort_by_file_name() {
318 let entry = entry?;
319 let from = entry.path();
320 if cx.ignore(from, is_post) {
321 continue;
322 }
323 if from.is_dir() {
324 let from = cx.rename(from)?;
325 let to = to.join(from.file_name().unwrap());
326 println!(" {}", to.display());
327 fs::create_dir(&to)?;
328 apply(cx, is_post, &from, &to)?;
329 } else if from.is_file() {
330 let from = cx.rename(from)?;
331 let to = to.join(from.file_name().unwrap());
332 cx.rewrite(&from)?;
333 println!(" {}", to.display());
334 fs::rename(from, to).unwrap();
335 }
336 }
337 Ok(())
338}
339
340struct Context {
341 template_root: PathBuf,
342 replace: ReplaceMap,
343 ignore_workspace: glob::Pattern,
344 ignore: Vec<glob::Pattern>,
345}
346impl Context {
347 fn new(template_root: &Path, mut template_keys: KeyMap, arg_keys: ArgsKeyMap, ignore: Vec<glob::Pattern>) -> io::Result<Self> {
348 for (i, (key, value)) in arg_keys.into_iter().enumerate() {
349 if key.is_empty() {
350 if i >= template_keys.len() {
351 return Err(io::Error::new(
352 io::ErrorKind::InvalidInput,
353 "more positional values them template keys",
354 ));
355 }
356 template_keys[i].value = Some(value);
357 } else if let Some(kv) = template_keys.iter_mut().find(|kv| kv.key == key) {
358 kv.value = Some(value);
359 } else {
360 return Err(io::Error::new(
361 io::ErrorKind::InvalidInput,
362 format!("unknown key `{key}`, not declared by template"),
363 ));
364 }
365 }
366 Ok(Self {
367 template_root: dunce::canonicalize(template_root)?,
368 replace: make_replacements(&template_keys)?,
369 ignore_workspace: glob::Pattern::new(".zng-template").unwrap(),
370 ignore,
371 })
372 }
373
374 fn ignore(&self, template_path: &Path, is_post: bool) -> bool {
375 let template_path = template_path.strip_prefix(&self.template_root).unwrap();
376
377 if !is_post && self.ignore_workspace.matches_path(template_path) {
378 return true;
379 }
380
381 for glob in &self.ignore {
382 if glob.matches_path(template_path) {
383 return true;
384 }
385 }
386 false
387 }
388
389 fn rename(&self, template_path: &Path) -> io::Result<PathBuf> {
390 let mut path = template_path.to_string_lossy().into_owned();
391 for (key, value) in &self.replace {
392 let s_value;
393 let value = if is_sanitized_key(key) {
394 value
395 } else {
396 s_value = sanitise_file_name::sanitize(value);
397 &s_value
398 };
399 path = path.replace(key, value);
400 }
401 let path = PathBuf::from(path);
402 if template_path != path {
403 fs::rename(template_path, &path)?;
404 }
405 Ok(path)
406 }
407
408 fn rewrite(&self, template_path: &Path) -> io::Result<()> {
409 match fs::read_to_string(template_path) {
410 Ok(txt) => {
411 let mut new_txt = txt.clone();
412 for (key, value) in &self.replace {
413 new_txt = new_txt.replace(key, value);
414 }
415 if new_txt != txt {
416 fs::write(template_path, new_txt.as_bytes())?;
417 }
418 Ok(())
419 }
420 Err(e) => {
421 if e.kind() == io::ErrorKind::InvalidData {
422 Ok(())
424 } else {
425 Err(e)
426 }
427 }
428 }
429 }
430}
431
432static PATTERNS: &[(&str, &str, Option<Case>)] = &[
433 ("t-key-t", "kebab-case", Some(Case::Kebab)),
434 ("T-KEY-T", "UPPER-KEBAB-CASE", Some(Case::UpperKebab)),
435 ("t_key_t", "snake_case", Some(Case::Snake)),
436 ("T_KEY_T", "UPPER_SNAKE_CASE", Some(Case::UpperSnake)),
437 ("T-Key-T", "Train-Case", Some(Case::Train)),
438 ("t.key.t", "lower case", Some(Case::Lower)),
439 ("T.KEY.T", "UPPER CASE", Some(Case::Upper)),
440 ("T.Key.T", "Title Case", Some(Case::Title)),
441 ("ttKeyTt", "camelCase", Some(Case::Camel)),
442 ("TtKeyTt", "PascalCase", Some(Case::Pascal)),
443 ("{{key}}", "Unchanged", None),
444 ("f-key-f", "Sanitized", None),
445 ("F-Key-F", "Title Sanitized", None),
446];
447
448type KeyMap = Vec<TemplateKey>;
449type ArgsKeyMap = Vec<(String, String)>;
450type ReplaceMap = Vec<(String, String)>;
451
452struct TemplateKey {
453 docs: String,
454 key: String,
455 value: Option<String>,
456 required: bool,
457}
458
459fn is_key(s: &str) -> bool {
460 s.len() >= 3 && s.is_ascii() && s.chars().all(|c| c.is_ascii_alphabetic() && c.is_lowercase())
461}
462
463fn parse_keys(zng_template_v1: String, include_docs: bool) -> io::Result<KeyMap> {
464 let mut r = vec![];
465
466 let mut docs = String::new();
467
468 for (i, line) in zng_template_v1.lines().enumerate() {
469 let line = line.trim();
470 if line.is_empty() {
471 docs.clear();
472 continue;
473 }
474
475 if line.starts_with('#') {
476 if include_docs {
477 let mut line = line.trim_start_matches('#');
478 if line.starts_with(' ') {
479 line = &line[1..];
480 }
481 docs.push_str(line);
482 docs.push('\n');
483 }
484 continue;
485 }
486
487 if r.is_empty() && line != "app=" {
488 return Err(io::Error::new(
489 io::ErrorKind::InvalidData,
490 "broken template, first key must be `app=`",
491 ));
492 }
493
494 let docs = mem::take(&mut docs);
495 if let Some((key, val)) = line.split_once('=') {
496 if is_key(key) {
497 if val.is_empty() {
498 r.push(TemplateKey {
499 docs,
500 key: key.to_owned(),
501 value: None,
502 required: true,
503 });
504 continue;
505 } else if val.starts_with('"') && val.ends_with('"') {
506 r.push(TemplateKey {
507 docs,
508 key: key.to_owned(),
509 value: Some(val[1..val.len() - 1].to_owned()),
510 required: false,
511 });
512 continue;
513 }
514 }
515 }
516 return Err(io::Error::new(
517 io::ErrorKind::InvalidData,
518 format!("broken template, invalid syntax in `.zng-template:{}`", i + 1),
519 ));
520 }
521
522 Ok(r)
523}
524fn make_replacements(keys: &KeyMap) -> io::Result<ReplaceMap> {
525 let mut r = Vec::with_capacity(keys.len() * PATTERNS.len());
526 for kv in keys {
527 let value = match &kv.value {
528 Some(v) => v,
529 None => {
530 return Err(io::Error::new(
531 io::ErrorKind::InvalidInput,
532 format!("missing required key `{}`", kv.key),
533 ));
534 }
535 };
536 let clean_value = util::clean_value(value, kv.required)?;
537
538 for (pattern, _, case) in PATTERNS {
539 let prefix = &pattern[..2];
540 let suffix = &pattern[pattern.len() - 2..];
541 let (key, value) = if let Some(case) = case {
542 let key_case = match case {
543 Case::Camel => Case::Pascal,
544 c => *c,
545 };
546 let value = match !pattern.contains('.') && !pattern.contains('{') {
547 true => &clean_value,
548 false => value,
549 };
550 (kv.key.to_case(key_case), value.to_case(*case))
551 } else {
552 (kv.key.to_owned(), value.to_owned())
553 };
554 let value = if is_sanitized_key(&key) {
555 sanitise_file_name::sanitize(&value)
556 } else {
557 value
558 };
559 let key = format!("{prefix}{key}{suffix}");
560 r.push((key, value));
561 }
562 }
563 Ok(r)
564}
565
566fn is_sanitized_key(key: &str) -> bool {
567 key.starts_with('f') || key.starts_with('F')
568}