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!(
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 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}