cargo_zng/
new.rs

1//! Initialize a new project from a Zng template repository
2
3use 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    /// Set template values by position
17    ///
18    /// The first value for all templates is the app name.
19    ///
20    /// EXAMPLE
21    ///
22    /// cargo zng new "My App!" | creates a "my-app" project.
23    ///
24    /// cargo zng new "my_app"  | creates a "my_app" project.
25    #[arg(num_args(0..))]
26    value: Vec<String>,
27
28    /// Zng template
29    ///
30    /// Can be a .git URL or an `owner/repo` for a GitHub repository.
31    /// Can also be an absolute path or `./path` to a local template directory.
32    ///
33    /// Use `#branch` to select a branch, that is `owner/repo#branch`.
34    #[arg(short, long, default_value = "zng-ui/zng-template")]
35    template: String,
36
37    /// Set a template value
38    ///
39    /// Templates have a `.zng-template/keys` file that defines the possible options.
40    ///
41    /// EXAMPLE
42    ///
43    /// -s"key=value" -s"k2=v2"
44    #[arg(short, long, num_args(0..))]
45    set: Vec<String>,
46
47    /// Show all possible values that can be set on the template
48    #[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    // validate name and init
70    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    // clone template
94    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    // generate template
111    if let Err(e) = apply_template(&cx, &project_name) {
112        error!("cannot generate, {e}");
113        fatal_cleanup();
114        util::exit();
115    }
116
117    // cargo zng fmt
118    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    /// Clone repository, if it is a template return the `.zng-template/keys,ignore` files contents.
212    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    // remove template .git
279    fs::remove_dir_all(template_temp.join(".git"))?;
280
281    // replace keys in post scripts
282    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            // SAFETY: cargo-zng new is single-threaded
292            std::env::set_var("ZNG_TEMPLATE_POST_DIR", &post);
293        }
294    }
295
296    // rename/rewrite template and move it to new package dir
297    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    // remove template temp
324    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                    // not utf-8 text file
434                    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}