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 #[arg(long)]
34 template: Option<String>,
35
36 #[arg(long)]
38 extension: bool,
39
40 #[command(flatten)]
42 function_options: functions::Options,
43
44 #[command(flatten)]
46 extension_options: extensions::Options,
47
48 #[arg(short, long)]
50 open: bool,
51
52 #[arg(long, alias = "function-name")]
54 bin_name: Option<String>,
55
56 #[arg(short = 'y', long, alias = "default")]
58 no_interactive: bool,
59
60 #[arg(long)]
62 render_file: Option<Vec<PathBuf>>,
63
64 #[arg(long)]
66 render_var: Option<Vec<String>>,
67
68 #[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 #[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 #[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 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}