cargo_lambda_new/
lib.rs

1use cargo_lambda_interactive::{
2    command::new_command, is_user_cancellation_error, progress::Progress,
3};
4use cargo_lambda_metadata::fs::{copy_and_replace, copy_without_replace};
5use clap::Args;
6use liquid::{Object, Parser, ParserBuilder, model::Value};
7use miette::{IntoDiagnostic, Result, WrapErr};
8use regex::Regex;
9use std::{
10    collections::HashMap,
11    env,
12    fmt::Debug,
13    fs::{File, copy as copy_file, create_dir_all},
14    path::{Path, PathBuf},
15};
16use template::{TemplateRoot, config::TemplateConfig};
17use walkdir::WalkDir;
18
19use crate::template::TemplateSource;
20
21mod error;
22use error::CreateError;
23
24mod events;
25mod extensions;
26mod functions;
27mod template;
28
29#[derive(Args, Clone, Debug)]
30#[group(skip)]
31struct Config {
32    /// Where to find the project template. It can be a local directory, a local zip file, or a URL to a remote zip file
33    #[arg(long)]
34    template: Option<String>,
35
36    /// Start a project for a Lambda Extension
37    #[arg(long)]
38    extension: bool,
39
40    /// Options for function templates
41    #[command(flatten)]
42    function_options: functions::Options,
43
44    /// Options for extension templates
45    #[command(flatten)]
46    extension_options: extensions::Options,
47
48    /// Open the project in a code editor defined by the environment variable EDITOR
49    #[arg(short, long)]
50    open: bool,
51
52    /// Name of the binary, independent of the package's name
53    #[arg(long, alias = "function-name")]
54    bin_name: Option<String>,
55
56    /// Apply the default template values without any prompt
57    #[arg(short = 'y', long, alias = "default")]
58    no_interactive: bool,
59
60    /// List of additional files to render with the template engine
61    #[arg(long)]
62    render_file: Option<Vec<PathBuf>>,
63
64    /// Map of additional variables to pass to the template engine, in KEY=VALUE format
65    #[arg(long)]
66    render_var: Option<Vec<String>>,
67
68    /// List of files to ignore from the template
69    #[arg(long)]
70    ignore_file: Option<Vec<PathBuf>>,
71}
72
73#[derive(Args, Clone, Debug)]
74#[command(
75    name = "init",
76    after_help = "Full command documentation: https://www.cargo-lambda.info/commands/init.html"
77)]
78pub struct Init {
79    #[command(flatten)]
80    config: Config,
81
82    /// Name of the Rust package, defaults to the directory name
83    #[arg(long)]
84    name: Option<String>,
85
86    #[arg(default_value = ".")]
87    path: PathBuf,
88}
89
90impl Init {
91    #[tracing::instrument(skip(self), target = "cargo_lambda")]
92    pub async fn run(&mut self) -> Result<()> {
93        if !self.path.is_dir() {
94            Err(CreateError::NotADirectoryPath(self.path.to_path_buf()))?;
95        }
96
97        if self.path.join("Cargo.toml").is_file() {
98            Err(CreateError::InvalidPackageRoot)?;
99        }
100
101        let path = dunce::canonicalize(&self.path).map_err(CreateError::InvalidPath)?;
102
103        let name = self
104            .name
105            .as_deref()
106            .or_else(|| path.file_name().and_then(|s| s.to_str()))
107            .ok_or_else(|| miette::miette!("invalid package name"))?;
108
109        new_project(name, &path, &mut self.config, false).await
110    }
111}
112
113#[derive(Args, Clone, Debug)]
114#[command(
115    name = "new",
116    after_help = "Full command documentation: https://www.cargo-lambda.info/commands/new.html"
117)]
118pub struct New {
119    #[command(flatten)]
120    config: Config,
121
122    /// Name of the Rust package to create
123    #[arg()]
124    name: String,
125}
126
127impl New {
128    #[tracing::instrument(skip(self), target = "cargo_lambda")]
129    pub async fn run(&mut self) -> Result<()> {
130        new_project(&self.name, &self.name, &mut self.config, true).await
131    }
132}
133
134#[tracing::instrument(target = "cargo_lambda")]
135async fn new_project<T: AsRef<Path> + Debug>(
136    name: &str,
137    path: T,
138    config: &mut Config,
139    replace: bool,
140) -> Result<()> {
141    tracing::trace!(name, ?path, ?config, "creating new project");
142
143    validate_name(name)?;
144    if let Some(name) = &config.bin_name {
145        validate_name(name)?;
146    }
147
148    let template = get_template(config).await?;
149    template.cleanup();
150
151    let template_config = template::config::parse_template_config(template.config_path())?;
152    let ignore_default_prompts = template_config.disable_default_prompts || config.no_interactive;
153
154    if config.extension {
155        config.extension_options.validate_options()?;
156    } else {
157        match config
158            .function_options
159            .validate_options(ignore_default_prompts)
160        {
161            Err(CreateError::UnexpectedInput(err)) if is_user_cancellation_error(&err) => {
162                return Ok(());
163            }
164            Err(err) => return Err(err.into()),
165            Ok(()) => {}
166        }
167    }
168
169    let globals = build_template_variables(config, &template_config, name)?;
170    let render_files = build_render_files(config, &template_config);
171    let ignore_files = build_ignore_files(config, &template_config);
172
173    create_project(
174        &path,
175        &template.final_path(),
176        &template_config,
177        &globals,
178        &render_files,
179        &ignore_files,
180        replace,
181    )
182    .await?;
183    if config.open {
184        let path_ref = path.as_ref();
185        let path_str = path_ref
186            .to_str()
187            .ok_or_else(|| CreateError::NotADirectoryPath(path_ref.to_path_buf()))?;
188        open_code_editor(path_str).await
189    } else {
190        Ok(())
191    }
192}
193
194async fn get_template(config: &Config) -> Result<TemplateRoot> {
195    let progress = Progress::start("downloading template");
196
197    let template_option = match config.template.as_deref() {
198        Some(t) => t,
199        None if config.extension => extensions::DEFAULT_TEMPLATE_URL,
200        None => functions::DEFAULT_TEMPLATE_URL,
201    };
202
203    let template_source = TemplateSource::try_from(template_option);
204    match template_source {
205        Ok(ts) => {
206            let result = ts.expand().await;
207            progress.finish_and_clear();
208            result
209        }
210        Err(e) => {
211            progress.finish_and_clear();
212            Err(e)
213        }
214    }
215}
216
217#[tracing::instrument(target = "cargo_lambda")]
218async fn create_project<T: AsRef<Path> + Debug>(
219    path: T,
220    template_path: &Path,
221    template_config: &TemplateConfig,
222    globals: &Object,
223    render_files: &[PathBuf],
224    ignore_files: &[PathBuf],
225    replace: bool,
226) -> Result<()> {
227    tracing::trace!("rendering new project's template");
228
229    let parser = ParserBuilder::with_stdlib().build().into_diagnostic()?;
230
231    let render_dir = tempfile::tempdir().into_diagnostic()?;
232    let render_path = render_dir.path();
233
234    let walk_dir = WalkDir::new(template_path).follow_links(false);
235    for entry in walk_dir {
236        let entry = entry.into_diagnostic()?;
237        let entry_path = entry.path();
238
239        let entry_name = entry_path
240            .file_name()
241            .ok_or_else(|| CreateError::InvalidTemplateEntry(entry_path.to_path_buf()))?;
242
243        if entry_path.is_dir() {
244            if entry_name != ".git" {
245                create_dir_all(entry_path)
246                    .into_diagnostic()
247                    .wrap_err_with(|| format!("unable to create directory: {entry_path:?}"))?;
248            }
249        } else if entry_name == "cargo-lambda-template.zip" {
250            continue;
251        } else {
252            let relative = entry_path.strip_prefix(template_path).into_diagnostic()?;
253
254            if should_ignore_file(relative, ignore_files, template_config, globals) {
255                continue;
256            }
257
258            let mut new_path = render_path.join(relative);
259            if let Some(path) = render_path_with_variables(&new_path, &parser, globals) {
260                new_path = path;
261            }
262
263            let parent_name = if let Some(parent) = new_path.parent() {
264                create_dir_all(parent).into_diagnostic()?;
265                parent.file_name().and_then(|p| p.to_str())
266            } else {
267                None
268            };
269
270            if entry_name == "Cargo.toml"
271                || entry_name == "README.md"
272                || (entry_name == "main.rs" && parent_name == Some("src"))
273                || (entry_name == "lib.rs" && parent_name == Some("src"))
274                || parent_name == Some("bin")
275                || should_render_file(relative, render_files, template_config, globals)
276            {
277                let template = parser.parse_file(entry_path).into_diagnostic()?;
278
279                let mut file = File::create(&new_path)
280                    .into_diagnostic()
281                    .wrap_err_with(|| format!("unable to create file: {new_path:?}"))?;
282
283                template
284                    .render_to(&mut file, globals)
285                    .into_diagnostic()
286                    .wrap_err_with(|| format!("failed to render template file: {:?}", &new_path))?;
287            } else {
288                copy_file(entry_path, &new_path)
289                    .into_diagnostic()
290                    .wrap_err_with(|| {
291                        format!(
292                            "failed to copy file: from {:?} to {:?}",
293                            &entry_path, &new_path
294                        )
295                    })?;
296            }
297        }
298    }
299
300    let res = if replace {
301        copy_and_replace(render_path, &path)
302    } else {
303        copy_without_replace(render_path, &path)
304    };
305
306    res.into_diagnostic()
307        .wrap_err_with(|| format!("failed to create package: template {render_path:?} to {path:?}"))
308}
309
310pub(crate) fn validate_name(name: &str) -> Result<()> {
311    // TODO(david): use a more extensive verification.
312    // See what Cargo does in https://github.com/rust-lang/cargo/blob/42696ae234dfb7b23c9638ad118373826c784c60/src/cargo/util/restricted_names.rs
313    let valid_ident = Regex::new(r"^([a-zA-Z][a-zA-Z0-9_-]+)$").into_diagnostic()?;
314
315    match valid_ident.is_match(name) {
316        true => Ok(()),
317        false => Err(CreateError::InvalidPackageName(name.to_string()).into()),
318    }
319}
320
321async fn open_code_editor(path: &str) -> Result<()> {
322    let editor = env::var("EDITOR").unwrap_or_default();
323    let editor = editor.trim();
324    if editor.is_empty() {
325        return Err(CreateError::InvalidEditor(path.into()).into());
326    }
327
328    let mut child = new_command(editor)
329        .args([path])
330        .spawn()
331        .into_diagnostic()
332        .wrap_err_with(|| format!("Failed to run `{editor} {path}`"))?;
333
334    child
335        .wait()
336        .await
337        .into_diagnostic()
338        .wrap_err_with(|| format!("Failed to wait on {editor} process"))
339        .map(|_| ())
340}
341
342fn render_variables(config: &Config) -> Object {
343    let vars = config.render_var.clone().unwrap_or_default();
344    let mut map = HashMap::new();
345
346    for var in vars {
347        let mut split = var.splitn(2, '=');
348        if let (Some(k), Some(v)) = (split.next(), split.next()) {
349            map.insert(k.to_string(), v.to_string());
350        }
351    }
352
353    let mut object = Object::new();
354    for (k, v) in map {
355        object.insert(k.into(), Value::scalar(v));
356    }
357
358    object
359}
360
361fn build_template_variables(
362    config: &Config,
363    template_config: &TemplateConfig,
364    name: &str,
365) -> Result<Object> {
366    let mut variables = liquid::object!({
367        "project_name": name,
368        "binary_name": config.bin_name,
369    });
370
371    if config.extension {
372        variables.extend(config.extension_options.variables()?);
373    } else {
374        variables.extend(config.function_options.variables(name, &config.bin_name)?);
375    };
376
377    if !template_config.prompts.is_empty() {
378        let template_variables = template_config.ask_template_options(config.no_interactive)?;
379        variables.extend(template_variables);
380    }
381
382    variables.extend(render_variables(config));
383    tracing::debug!(?variables, "collected template variables");
384
385    Ok(variables)
386}
387
388fn build_render_files(config: &Config, template_config: &TemplateConfig) -> Vec<PathBuf> {
389    let mut render_files = template_config.render_files.clone();
390    render_files.extend(config.render_file.clone().unwrap_or_default());
391    render_files
392}
393
394fn build_ignore_files(config: &Config, template_config: &TemplateConfig) -> Vec<PathBuf> {
395    let mut ignore_files = template_config.ignore_files.clone();
396    ignore_files.extend(config.ignore_file.clone().unwrap_or_default());
397    ignore_files
398}
399
400fn should_render_file(
401    relative: &Path,
402    render_files: &[PathBuf],
403    template_config: &TemplateConfig,
404    variables: &Object,
405) -> bool {
406    if template_config.render_all_files {
407        return true;
408    }
409
410    if render_files.contains(&relative.to_path_buf()) {
411        return true;
412    }
413
414    let Some(unix_path) = convert_to_unix_path(relative) else {
415        return false;
416    };
417
418    if render_files.contains(&PathBuf::from(&unix_path)) {
419        return true;
420    }
421
422    let condition = template_config
423        .render_conditional_files
424        .get(&unix_path)
425        .or_else(|| {
426            relative
427                .to_str()
428                .and_then(|s| template_config.render_conditional_files.get(s))
429        });
430
431    if let Some(condition) = condition {
432        let Some(variable) = variables.get::<str>(&condition.var) else {
433            return false;
434        };
435
436        if let Some(condition_value) = &condition.r#match {
437            if condition_value.to_value() == *variable {
438                return true;
439            }
440        }
441
442        if let Some(condition_value) = &condition.not_match {
443            if condition_value.to_value() != *variable {
444                return true;
445            }
446        }
447    }
448
449    false
450}
451
452fn should_ignore_file(
453    relative: &Path,
454    ignore_files: &[PathBuf],
455    template_config: &TemplateConfig,
456    variables: &Object,
457) -> bool {
458    if ignore_files.contains(&relative.to_path_buf()) {
459        return true;
460    }
461
462    let Some(unix_path) = convert_to_unix_path(relative) else {
463        return false;
464    };
465
466    if ignore_files.contains(&PathBuf::from(&unix_path)) {
467        return true;
468    }
469
470    let condition = template_config
471        .ignore_conditional_files
472        .get(&unix_path)
473        .or_else(|| {
474            relative
475                .to_str()
476                .and_then(|s| template_config.ignore_conditional_files.get(s))
477        });
478
479    if let Some(condition) = condition {
480        let Some(variable) = variables.get::<str>(&condition.var) else {
481            return false;
482        };
483
484        if let Some(condition_value) = &condition.r#match {
485            if condition_value.to_value() == *variable {
486                return true;
487            }
488        }
489
490        if let Some(condition_value) = &condition.not_match {
491            if condition_value.to_value() != *variable {
492                return true;
493            }
494        }
495    }
496
497    false
498}
499
500fn render_path_with_variables(path: &Path, parser: &Parser, variables: &Object) -> Option<PathBuf> {
501    let re = regex::Regex::new(r"\{\{[^/]*\}\}").ok()?;
502
503    let path_str = path.to_string_lossy();
504    if !re.is_match(&path_str) {
505        return None;
506    }
507
508    let template = parser.parse(&path_str).ok()?;
509    let path_str = template.render(&variables).ok()?;
510
511    Some(PathBuf::from(path_str))
512}
513
514#[cfg(target_os = "windows")]
515fn convert_to_unix_path(path: &Path) -> Option<String> {
516    let mut path_str = String::new();
517    for component in path.components() {
518        if let std::path::Component::Normal(os_str) = component {
519            if !path_str.is_empty() {
520                path_str.push('/');
521            }
522            path_str.push_str(os_str.to_str()?);
523        }
524    }
525    Some(path_str)
526}
527
528#[cfg(not(target_os = "windows"))]
529fn convert_to_unix_path(path: &Path) -> Option<String> {
530    path.to_str().map(String::from)
531}
532
533#[cfg(test)]
534mod tests {
535    use liquid::{Object, model::Value};
536    use template::config::{PromptValue, RenderCondition};
537
538    use super::*;
539
540    #[test]
541    fn test_render_relative_path_with_render_conditional_files() {
542        #[cfg(not(target_os = "windows"))]
543        let path = Path::new("src/main.rs");
544        #[cfg(target_os = "windows")]
545        let path = Path::new("src\\main.rs");
546
547        let render_files = vec![];
548        let mut template_config = TemplateConfig::default();
549        template_config.render_conditional_files.insert(
550            "src/main.rs".into(),
551            RenderCondition {
552                var: "render_main_rs".into(),
553                r#match: Some(PromptValue::Boolean(true)),
554                not_match: None,
555            },
556        );
557        let mut variables = Object::new();
558        variables.insert("render_main_rs".into(), Value::scalar(true));
559
560        assert!(should_render_file(
561            path,
562            &render_files,
563            &template_config,
564            &variables
565        ));
566    }
567
568    #[test]
569    fn test_render_relative_path_with_render_files() {
570        #[cfg(not(target_os = "windows"))]
571        let path = Path::new("src/main.rs");
572        #[cfg(target_os = "windows")]
573        let path = Path::new("src\\main.rs");
574
575        let render_files = vec![PathBuf::from("src/main.rs")];
576        let template_config = TemplateConfig::default();
577        let variables = Object::new();
578        assert!(should_render_file(
579            path,
580            &render_files,
581            &template_config,
582            &variables
583        ));
584    }
585
586    #[test]
587    fn test_render_relative_path_with_render_conditional_files_false() {
588        #[cfg(not(target_os = "windows"))]
589        let path = Path::new("src/main.rs");
590        #[cfg(target_os = "windows")]
591        let path = Path::new("src\\main.rs");
592
593        let render_files = vec![];
594        let template_config = TemplateConfig::default();
595        let variables = Object::new();
596        assert!(!should_render_file(
597            path,
598            &render_files,
599            &template_config,
600            &variables
601        ));
602    }
603
604    #[test]
605    fn test_render_relative_path_with_render_all_files() {
606        #[cfg(not(target_os = "windows"))]
607        let path = Path::new("src/main.rs");
608        #[cfg(target_os = "windows")]
609        let path = Path::new("src\\main.rs");
610
611        let render_files = vec![];
612        let template_config = TemplateConfig {
613            render_all_files: true,
614            ..Default::default()
615        };
616        let variables = Object::new();
617        assert!(should_render_file(
618            path,
619            &render_files,
620            &template_config,
621            &variables
622        ));
623    }
624
625    #[test]
626    fn test_render_path_with_variables() {
627        #[cfg(not(target_os = "windows"))]
628        let path = Path::new("{{ci_provider}}/actions/build.yml");
629        #[cfg(target_os = "windows")]
630        let path = Path::new("{{ci_provider}}\\actions\\build.yml");
631
632        #[cfg(not(target_os = "windows"))]
633        let expected = PathBuf::from(".github/actions/build.yml");
634        #[cfg(target_os = "windows")]
635        let expected = PathBuf::from(".github\\actions\\build.yml");
636
637        let parser = ParserBuilder::with_stdlib().build().unwrap();
638        let mut variables = Object::new();
639        variables.insert("ci_provider".into(), Value::scalar(".github"));
640
641        assert_eq!(
642            render_path_with_variables(path, &parser, &variables),
643            Some(expected)
644        );
645    }
646
647    #[test]
648    fn test_should_ignore_file() {
649        #[cfg(not(target_os = "windows"))]
650        let path = Path::new("src/http.rs");
651        #[cfg(target_os = "windows")]
652        let path = Path::new("src\\http.rs");
653
654        let ignore_files = vec![];
655        let mut template_config = TemplateConfig::default();
656        template_config.ignore_conditional_files.insert(
657            "src/http.rs".into(),
658            RenderCondition {
659                var: "http_function".into(),
660                r#match: None,
661                not_match: Some(PromptValue::Boolean(true)),
662            },
663        );
664
665        let mut variables = Object::new();
666        variables.insert("http_function".into(), Value::scalar(false));
667
668        assert!(should_ignore_file(
669            path,
670            &ignore_files,
671            &template_config,
672            &variables
673        ));
674    }
675
676    #[test]
677    fn test_should_not_ignore_file() {
678        #[cfg(not(target_os = "windows"))]
679        let path = Path::new("src/http.rs");
680        #[cfg(target_os = "windows")]
681        let path = Path::new("src\\http.rs");
682
683        let ignore_files = vec![];
684        let mut template_config = TemplateConfig::default();
685        template_config.ignore_conditional_files.insert(
686            "src/http.rs".into(),
687            RenderCondition {
688                var: "http_function".into(),
689                r#match: None,
690                not_match: Some(PromptValue::Boolean(true)),
691            },
692        );
693
694        let mut variables = Object::new();
695        variables.insert("http_function".into(), Value::scalar(true));
696
697        assert!(!should_ignore_file(
698            path,
699            &ignore_files,
700            &template_config,
701            &variables
702        ));
703    }
704}