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