Skip to main content

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!("failed to copy file: from {entry_path:?} to {new_path:?}",)
292                    })?;
293            }
294        }
295    }
296
297    let res = if replace {
298        copy_and_replace(render_path, &path)
299    } else {
300        copy_without_replace(render_path, &path)
301    };
302
303    res.into_diagnostic()
304        .wrap_err_with(|| format!("failed to create package: template {render_path:?} to {path:?}"))
305}
306
307pub(crate) fn validate_name(name: &str) -> Result<()> {
308    // TODO(david): use a more extensive verification.
309    // See what Cargo does in https://github.com/rust-lang/cargo/blob/42696ae234dfb7b23c9638ad118373826c784c60/src/cargo/util/restricted_names.rs
310    let valid_ident = Regex::new(r"^([a-zA-Z][a-zA-Z0-9_-]+)$").into_diagnostic()?;
311
312    match valid_ident.is_match(name) {
313        true => Ok(()),
314        false => Err(CreateError::InvalidPackageName(name.to_string()).into()),
315    }
316}
317
318async fn open_code_editor(path: &str) -> Result<()> {
319    let editor = env::var("EDITOR").unwrap_or_default();
320    let editor = editor.trim();
321    if editor.is_empty() {
322        return Err(CreateError::InvalidEditor(path.into()).into());
323    }
324
325    let mut child = new_command(editor)
326        .args([path])
327        .spawn()
328        .into_diagnostic()
329        .wrap_err_with(|| format!("Failed to run `{editor} {path}`"))?;
330
331    child
332        .wait()
333        .await
334        .into_diagnostic()
335        .wrap_err_with(|| format!("Failed to wait on {editor} process"))
336        .map(|_| ())
337}
338
339fn render_variables(config: &Config) -> Object {
340    let vars = config.render_var.clone().unwrap_or_default();
341    let mut map = HashMap::new();
342
343    for var in vars {
344        let mut split = var.splitn(2, '=');
345        if let (Some(k), Some(v)) = (split.next(), split.next()) {
346            map.insert(k.to_string(), v.to_string());
347        }
348    }
349
350    let mut object = Object::new();
351    for (k, v) in map {
352        object.insert(k.into(), Value::scalar(v));
353    }
354
355    object
356}
357
358fn build_template_variables(
359    config: &Config,
360    template_config: &TemplateConfig,
361    name: &str,
362) -> Result<Object> {
363    let mut variables = liquid::object!({
364        "project_name": name,
365        "binary_name": config.bin_name,
366    });
367
368    if config.extension {
369        variables.extend(config.extension_options.variables()?);
370    } else {
371        variables.extend(config.function_options.variables(name, &config.bin_name)?);
372    };
373
374    if !template_config.prompts.is_empty() {
375        let template_variables = template_config.ask_template_options(config.no_interactive)?;
376        variables.extend(template_variables);
377    }
378
379    variables.extend(render_variables(config));
380    tracing::debug!(?variables, "collected template variables");
381
382    Ok(variables)
383}
384
385fn build_render_files(config: &Config, template_config: &TemplateConfig) -> Vec<PathBuf> {
386    let mut render_files = template_config.render_files.clone();
387    render_files.extend(config.render_file.clone().unwrap_or_default());
388    render_files
389}
390
391fn build_ignore_files(config: &Config, template_config: &TemplateConfig) -> Vec<PathBuf> {
392    let mut ignore_files = template_config.ignore_files.clone();
393    ignore_files.extend(config.ignore_file.clone().unwrap_or_default());
394    ignore_files
395}
396
397fn should_render_file(
398    relative: &Path,
399    render_files: &[PathBuf],
400    template_config: &TemplateConfig,
401    variables: &Object,
402) -> bool {
403    if template_config.render_all_files {
404        return true;
405    }
406
407    if render_files.contains(&relative.to_path_buf()) {
408        return true;
409    }
410
411    let Some(unix_path) = convert_to_unix_path(relative) else {
412        return false;
413    };
414
415    if render_files.contains(&PathBuf::from(&unix_path)) {
416        return true;
417    }
418
419    let condition = template_config
420        .render_conditional_files
421        .get(&unix_path)
422        .or_else(|| {
423            relative
424                .to_str()
425                .and_then(|s| template_config.render_conditional_files.get(s))
426        });
427
428    if let Some(condition) = condition {
429        let Some(variable) = variables.get::<str>(&condition.var) else {
430            return false;
431        };
432
433        if let Some(condition_value) = &condition.r#match
434            && condition_value.to_value() == *variable
435        {
436            return true;
437        }
438
439        if let Some(condition_value) = &condition.not_match
440            && condition_value.to_value() != *variable
441        {
442            return true;
443        }
444    }
445
446    false
447}
448
449fn should_ignore_file(
450    relative: &Path,
451    ignore_files: &[PathBuf],
452    template_config: &TemplateConfig,
453    variables: &Object,
454) -> bool {
455    if ignore_files.contains(&relative.to_path_buf()) {
456        return true;
457    }
458
459    let Some(unix_path) = convert_to_unix_path(relative) else {
460        return false;
461    };
462
463    if ignore_files.contains(&PathBuf::from(&unix_path)) {
464        return true;
465    }
466
467    let condition = template_config
468        .ignore_conditional_files
469        .get(&unix_path)
470        .or_else(|| {
471            relative
472                .to_str()
473                .and_then(|s| template_config.ignore_conditional_files.get(s))
474        });
475
476    if let Some(condition) = condition {
477        let Some(variable) = variables.get::<str>(&condition.var) else {
478            return false;
479        };
480
481        if let Some(condition_value) = &condition.r#match
482            && condition_value.to_value() == *variable
483        {
484            return true;
485        }
486
487        if let Some(condition_value) = &condition.not_match
488            && condition_value.to_value() != *variable
489        {
490            return true;
491        }
492    }
493
494    false
495}
496
497fn render_path_with_variables(path: &Path, parser: &Parser, variables: &Object) -> Option<PathBuf> {
498    let re = regex::Regex::new(r"\{\{[^/]*\}\}").ok()?;
499
500    let path_str = path.to_string_lossy();
501    if !re.is_match(&path_str) {
502        return None;
503    }
504
505    let template = parser.parse(&path_str).ok()?;
506    let path_str = template.render(&variables).ok()?;
507
508    Some(PathBuf::from(path_str))
509}
510
511#[cfg(target_os = "windows")]
512fn convert_to_unix_path(path: &Path) -> Option<String> {
513    let mut path_str = String::new();
514    for component in path.components() {
515        if let std::path::Component::Normal(os_str) = component {
516            if !path_str.is_empty() {
517                path_str.push('/');
518            }
519            path_str.push_str(os_str.to_str()?);
520        }
521    }
522    Some(path_str)
523}
524
525#[cfg(not(target_os = "windows"))]
526fn convert_to_unix_path(path: &Path) -> Option<String> {
527    path.to_str().map(String::from)
528}
529
530#[cfg(test)]
531mod tests {
532    use liquid::{Object, model::Value};
533    use template::config::{PromptValue, RenderCondition};
534
535    use super::*;
536
537    #[test]
538    fn test_render_relative_path_with_render_conditional_files() {
539        #[cfg(not(target_os = "windows"))]
540        let path = Path::new("src/main.rs");
541        #[cfg(target_os = "windows")]
542        let path = Path::new("src\\main.rs");
543
544        let render_files = vec![];
545        let mut template_config = TemplateConfig::default();
546        template_config.render_conditional_files.insert(
547            "src/main.rs".into(),
548            RenderCondition {
549                var: "render_main_rs".into(),
550                r#match: Some(PromptValue::Boolean(true)),
551                not_match: None,
552            },
553        );
554        let mut variables = Object::new();
555        variables.insert("render_main_rs".into(), Value::scalar(true));
556
557        assert!(should_render_file(
558            path,
559            &render_files,
560            &template_config,
561            &variables
562        ));
563    }
564
565    #[test]
566    fn test_render_relative_path_with_render_files() {
567        #[cfg(not(target_os = "windows"))]
568        let path = Path::new("src/main.rs");
569        #[cfg(target_os = "windows")]
570        let path = Path::new("src\\main.rs");
571
572        let render_files = vec![PathBuf::from("src/main.rs")];
573        let template_config = TemplateConfig::default();
574        let variables = Object::new();
575        assert!(should_render_file(
576            path,
577            &render_files,
578            &template_config,
579            &variables
580        ));
581    }
582
583    #[test]
584    fn test_render_relative_path_with_render_conditional_files_false() {
585        #[cfg(not(target_os = "windows"))]
586        let path = Path::new("src/main.rs");
587        #[cfg(target_os = "windows")]
588        let path = Path::new("src\\main.rs");
589
590        let render_files = vec![];
591        let template_config = TemplateConfig::default();
592        let variables = Object::new();
593        assert!(!should_render_file(
594            path,
595            &render_files,
596            &template_config,
597            &variables
598        ));
599    }
600
601    #[test]
602    fn test_render_relative_path_with_render_all_files() {
603        #[cfg(not(target_os = "windows"))]
604        let path = Path::new("src/main.rs");
605        #[cfg(target_os = "windows")]
606        let path = Path::new("src\\main.rs");
607
608        let render_files = vec![];
609        let template_config = TemplateConfig {
610            render_all_files: true,
611            ..Default::default()
612        };
613        let variables = Object::new();
614        assert!(should_render_file(
615            path,
616            &render_files,
617            &template_config,
618            &variables
619        ));
620    }
621
622    #[test]
623    fn test_render_path_with_variables() {
624        #[cfg(not(target_os = "windows"))]
625        let path = Path::new("{{ci_provider}}/actions/build.yml");
626        #[cfg(target_os = "windows")]
627        let path = Path::new("{{ci_provider}}\\actions\\build.yml");
628
629        #[cfg(not(target_os = "windows"))]
630        let expected = PathBuf::from(".github/actions/build.yml");
631        #[cfg(target_os = "windows")]
632        let expected = PathBuf::from(".github\\actions\\build.yml");
633
634        let parser = ParserBuilder::with_stdlib().build().unwrap();
635        let mut variables = Object::new();
636        variables.insert("ci_provider".into(), Value::scalar(".github"));
637
638        assert_eq!(
639            render_path_with_variables(path, &parser, &variables),
640            Some(expected)
641        );
642    }
643
644    #[test]
645    fn test_should_ignore_file() {
646        #[cfg(not(target_os = "windows"))]
647        let path = Path::new("src/http.rs");
648        #[cfg(target_os = "windows")]
649        let path = Path::new("src\\http.rs");
650
651        let ignore_files = vec![];
652        let mut template_config = TemplateConfig::default();
653        template_config.ignore_conditional_files.insert(
654            "src/http.rs".into(),
655            RenderCondition {
656                var: "http_function".into(),
657                r#match: None,
658                not_match: Some(PromptValue::Boolean(true)),
659            },
660        );
661
662        let mut variables = Object::new();
663        variables.insert("http_function".into(), Value::scalar(false));
664
665        assert!(should_ignore_file(
666            path,
667            &ignore_files,
668            &template_config,
669            &variables
670        ));
671    }
672
673    #[test]
674    fn test_should_not_ignore_file() {
675        #[cfg(not(target_os = "windows"))]
676        let path = Path::new("src/http.rs");
677        #[cfg(target_os = "windows")]
678        let path = Path::new("src\\http.rs");
679
680        let ignore_files = vec![];
681        let mut template_config = TemplateConfig::default();
682        template_config.ignore_conditional_files.insert(
683            "src/http.rs".into(),
684            RenderCondition {
685                var: "http_function".into(),
686                r#match: None,
687                not_match: Some(PromptValue::Boolean(true)),
688            },
689        );
690
691        let mut variables = Object::new();
692        variables.insert("http_function".into(), Value::scalar(true));
693
694        assert!(!should_ignore_file(
695            path,
696            &ignore_files,
697            &template_config,
698            &variables
699        ));
700    }
701}