Repository: cargo-lambda/cargo-lambda (language: python)

Issue:
Make `cargo lambda new` work in an existent project
That subcommand is great to quickly scaffold new functions, however, it only works for new projects.

It'd be very nice if it also worked on existent projects, where it'd add the function code to an existent package.


Discussion / hints:
Cargo use a separate `cargo init` command for this: https://doc.rust-lang.org/cargo/commands/cargo-init.html , I think cargo-lambda should follow this common pattern.
> I think cargo-lambda should follow this common pattern.

I'm all for this. I forgot that cargo-init was a thing.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 84044c86..d03b1fa7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -812,6 +812,7 @@ dependencies = [
  "cargo-lambda-interactive",
  "cargo-lambda-metadata",
  "clap",
+ "dunce",
  "liquid",
  "miette 4.7.1",
  "regex",
diff --git a/Cargo.toml b/Cargo.toml
index a7c753b1..a985b83f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -48,6 +48,7 @@ cargo-lambda-new = { version = "0.14.0", path = "crates/cargo-lambda-new" }
 cargo-lambda-remote = { version = "0.14.0", path = "crates/cargo-lambda-remote" }
 cargo-lambda-watch = { version = "0.14.0", path = "crates/cargo-lambda-watch" }
 clap = { version = "4", features = ["derive"] }
+dunce = "1.0.3"
 home = "0.5.3"
 miette = { version = "4.7.1" }
 reqwest = { version = "0.11.10", default-features = false, features = [
diff --git a/crates/cargo-lambda-cli/src/main.rs b/crates/cargo-lambda-cli/src/main.rs
index 4e3f9b7b..d44dc22d 100644
--- a/crates/cargo-lambda-cli/src/main.rs
+++ b/crates/cargo-lambda-cli/src/main.rs
@@ -4,7 +4,7 @@ use std::boxed::Box;
 use cargo_lambda_build::{Build, Zig};
 use cargo_lambda_deploy::Deploy;
 use cargo_lambda_invoke::Invoke;
-use cargo_lambda_new::New;
+use cargo_lambda_new::{Init, New};
 use cargo_lambda_watch::Watch;
 use clap::{CommandFactory, Parser, Subcommand};
 use miette::{miette, IntoDiagnostic, Result};
@@ -37,6 +37,8 @@ enum LambdaSubcommand {
     Build(Box<Build>),
     /// Deploy Lambda functions to AWS
     Deploy(Deploy),
+    /// Initialize a package in a directory that already exists
+    Init(Init),
     /// Send requests to Lambda functions running on the emulator
     Invoke(Invoke),
     /// Create a new package with a Lambda function from our Lambda Template
@@ -50,6 +52,7 @@ impl LambdaSubcommand {
         match self {
             Self::Build(mut b) => b.run().await,
             Self::Deploy(d) => d.run().await,
+            Self::Init(mut i) => i.run().await,
             Self::Invoke(i) => i.run().await,
             Self::New(mut n) => n.run().await,
             Self::Watch(w) => w.run().await,
diff --git a/crates/cargo-lambda-metadata/src/fs/rename.rs b/crates/cargo-lambda-metadata/src/fs/rename.rs
index 25715b86..0465f6b5 100644
--- a/crates/cargo-lambda-metadata/src/fs/rename.rs
+++ b/crates/cargo-lambda-metadata/src/fs/rename.rs
@@ -56,7 +56,7 @@ where
     P: AsRef<Path>,
     Q: AsRef<Path>,
 {
-    fs::create_dir(&to)?;
+    fs::create_dir_all(&to)?;
     for entry in from.as_ref().read_dir()? {
         let entry = entry?;
         let kind = entry.file_type()?;
diff --git a/crates/cargo-lambda-new/Cargo.toml b/crates/cargo-lambda-new/Cargo.toml
index 6e7272b2..46bbee66 100644
--- a/crates/cargo-lambda-new/Cargo.toml
+++ b/crates/cargo-lambda-new/Cargo.toml
@@ -19,6 +19,7 @@ description.workspace = true
 cargo-lambda-interactive.workspace = true
 cargo-lambda-metadata.workspace = true
 clap.workspace = true
+dunce.workspace = true
 liquid = "0.26.0"
 miette.workspace = true
 regex = "1.5.5"
diff --git a/crates/cargo-lambda-new/src/error.rs b/crates/cargo-lambda-new/src/error.rs
index a52e8fc4..c5e389a9 100644
--- a/crates/cargo-lambda-new/src/error.rs
+++ b/crates/cargo-lambda-new/src/error.rs
@@ -1,4 +1,4 @@
-use std::path::PathBuf;
+use std::{io, path::PathBuf};
 
 use cargo_lambda_interactive::error::InquireError;
 use miette::Diagnostic;
@@ -18,4 +18,10 @@ pub(crate) enum CreateError {
     InvalidEditor(String),
     #[error("invalid package name: {0}")]
     InvalidPackageName(String),
+    #[error("the path doesn't exist: {0}")]
+    MissingPath(PathBuf),
+    #[error("the path is not a directory: {0}")]
+    NotADirectoryPath(PathBuf),
+    #[error(transparent)]
+    InvalidPath(#[from] io::Error),
 }
diff --git a/crates/cargo-lambda-new/src/lib.rs b/crates/cargo-lambda-new/src/lib.rs
index 1179e666..fc6b63f3 100644
--- a/crates/cargo-lambda-new/src/lib.rs
+++ b/crates/cargo-lambda-new/src/lib.rs
@@ -7,6 +7,7 @@ use regex::Regex;
 use std::{
     collections::HashMap,
     env,
+    fmt::Debug,
     fs::{copy as copy_file, create_dir_all, File},
     path::{Path, PathBuf},
 };
@@ -23,9 +24,8 @@ mod functions;
 mod template;
 
 #[derive(Args, Clone, Debug)]
-#[command(name = "new")]
 #[group(skip)]
-pub struct New {
+struct Config {
     /// Where to find the project template. It can be a local directory, a local zip file, or a URL to a remote zip file
     #[arg(long)]
     template: Option<String>,
@@ -65,193 +65,205 @@ pub struct New {
     /// List of files to ignore from the template
     #[arg(long)]
     ignore_file: Option<Vec<PathBuf>>,
+}
 
-    /// Name of the Rust package to create
-    #[arg()]
-    package_name: String,
+#[derive(Args, Clone, Debug)]
+#[command(name = "init")]
+pub struct Init {
+    #[command(flatten)]
+    config: Config,
+
+    /// Name of the Rust package, defaults to the directory name
+    #[arg(long)]
+    name: Option<String>,
+
+    #[arg(default_value = ".")]
+    path: PathBuf,
 }
 
-impl New {
+impl Init {
     #[tracing::instrument(skip(self), target = "cargo_lambda")]
     pub async fn run(&mut self) -> Result<()> {
-        tracing::trace!(options = ?self, "creating new project");
-
-        validate_name(&self.package_name)?;
-        if let Some(name) = &self.bin_name {
-            validate_name(name)?;
+        if !self.path.exists() {
+            Err(CreateError::MissingPath(self.path.to_path_buf()))?;
         }
 
-        if self.extension {
-            self.extension_options.validate_options()?;
-        } else {
-            match self.function_options.validate_options(self.no_interactive) {
-                Err(CreateError::UnexpectedInput(err)) if is_user_cancellation_error(&err) => {
-                    return Ok(())
-                }
-                Err(err) => return Err(err.into()),
-                Ok(()) => {}
-            }
+        if !self.path.is_dir() {
+            Err(CreateError::NotADirectoryPath(self.path.to_path_buf()))?;
         }
 
-        self.create_package().await?;
-        self.open_code_editor().await
-    }
+        let path = dunce::canonicalize(&self.path).map_err(CreateError::InvalidPath)?;
 
-    async fn create_package(&self) -> Result<()> {
-        let template_source = TemplateSource::try_from(self.template_option())?;
-        let template_path = template_source.expand().await?;
+        let name = self
+            .name
+            .as_deref()
+            .or_else(|| path.file_name().and_then(|s| s.to_str()))
+            .ok_or_else(|| miette::miette!("invalid package name"))?;
 
-        let parser = ParserBuilder::with_stdlib().build().into_diagnostic()?;
+        new_project(name, &path, &mut self.config).await
+    }
+}
 
-        let template_vars = if self.extension {
-            self.extension_options.variables()?
-        } else {
-            self.function_options
-                .variables(&self.package_name, &self.bin_name)?
-        };
-
-        let mut globals = liquid::object!({
-            "project_name": self.package_name,
-            "binary_name": self.bin_name,
-        });
-        globals.extend(template_vars);
-        globals.extend(self.render_variables());
-        tracing::debug!(variables = ?globals, "rendering templates");
-
-        let render_dir = tempfile::tempdir().into_diagnostic()?;
-        let render_path = render_dir.path();
-
-        let walk_dir = WalkDir::new(&template_path).follow_links(false);
-        for entry in walk_dir {
-            let entry = entry.into_diagnostic()?;
-            let entry_path = entry.path();
-
-            let entry_name = entry_path
-                .file_name()
-                .ok_or_else(|| CreateError::InvalidTemplateEntry(entry_path.to_path_buf()))?;
-
-            if entry_path.is_dir() {
-                if entry_name != ".git" {
-                    create_dir_all(entry_path)
-                        .into_diagnostic()
-                        .wrap_err_with(|| {
-                            format!("unable to create directory: {:?}", entry_path)
-                        })?;
-                }
-            } else if entry_name == "cargo-lambda-template.zip" {
-                continue;
-            } else {
-                let relative = entry_path.strip_prefix(&template_path).into_diagnostic()?;
-
-                let new_path = render_path.join(relative);
-                let parent_name = if let Some(parent) = new_path.parent() {
-                    create_dir_all(parent).into_diagnostic()?;
-                    parent.file_name().and_then(|p| p.to_str())
-                } else {
-                    None
-                };
-
-                if entry_name == "LICENSE" || self.is_ignore_file(relative) {
-                    continue;
-                }
-
-                if entry_name == "Cargo.toml"
-                    || entry_name == "README.md"
-                    || (entry_name == "main.rs" && parent_name == Some("src"))
-                    || (entry_name == "lib.rs" && parent_name == Some("src"))
-                    || parent_name == Some("bin")
-                    || self.is_render_file(relative)
-                {
-                    let template = parser.parse_file(entry_path).into_diagnostic()?;
-
-                    let mut file = File::create(&new_path)
-                        .into_diagnostic()
-                        .wrap_err_with(|| format!("unable to create file: {:?}", new_path))?;
-
-                    template
-                        .render_to(&mut file, &globals)
-                        .into_diagnostic()
-                        .wrap_err_with(|| {
-                            format!("failed to render template file: {:?}", &new_path)
-                        })?;
-                } else {
-                    copy_file(entry_path, &new_path)
-                        .into_diagnostic()
-                        .wrap_err_with(|| {
-                            format!(
-                                "failed to copy file: from {:?} to {:?}",
-                                &entry_path, &new_path
-                            )
-                        })?;
-                }
-            }
-        }
+#[derive(Args, Clone, Debug)]
+#[command(name = "new")]
+pub struct New {
+    #[command(flatten)]
+    config: Config,
 
-        rename(render_path, &self.package_name)
-            .into_diagnostic()
-            .wrap_err_with(|| {
-                format!(
-                    "failed to move package: from {:?} to {:?}",
-                    &render_path, &self.package_name
-                )
-            })?;
+    /// Name of the Rust package to create
+    #[arg()]
+    name: String,
+}
 
-        Ok(())
+impl New {
+    #[tracing::instrument(skip(self), target = "cargo_lambda")]
+    pub async fn run(&mut self) -> Result<()> {
+        new_project(&self.name, &self.name, &mut self.config).await
     }
+}
 
-    async fn open_code_editor(&self) -> Result<()> {
-        if !self.open {
-            return Ok(());
-        }
-        let editor = env::var("EDITOR").unwrap_or_default();
-        let editor = editor.trim();
-        if editor.is_empty() {
-            Err(CreateError::InvalidEditor(self.package_name.clone()).into())
-        } else {
-            silent_command(editor.trim(), &[&self.package_name]).await
-        }
+async fn new_project<T: AsRef<Path> + Debug>(
+    name: &str,
+    path: T,
+    config: &mut Config,
+) -> Result<()> {
+    tracing::trace!(name, ?path, ?config, "creating new project");
+
+    validate_name(name)?;
+    if let Some(name) = &config.bin_name {
+        validate_name(name)?;
     }
 
-    fn template_option(&self) -> &str {
-        match self.template.as_deref() {
-            Some(t) => t,
-            None if self.extension => extensions::DEFAULT_TEMPLATE_URL,
-            None => functions::DEFAULT_TEMPLATE_URL,
+    if config.extension {
+        config.extension_options.validate_options()?;
+    } else {
+        match config
+            .function_options
+            .validate_options(config.no_interactive)
+        {
+            Err(CreateError::UnexpectedInput(err)) if is_user_cancellation_error(&err) => {
+                return Ok(())
+            }
+            Err(err) => return Err(err.into()),
+            Ok(()) => {}
         }
     }
 
-    fn is_render_file(&self, path: &Path) -> bool {
-        self.render_file
-            .as_ref()
-            .map(|v| v.contains(&path.to_path_buf()))
-            .unwrap_or(false)
+    create_project(name, &path, config).await?;
+    if config.open {
+        let path_ref = path.as_ref();
+        let path_str = path_ref
+            .to_str()
+            .ok_or_else(|| CreateError::NotADirectoryPath(path_ref.to_path_buf()))?;
+        open_code_editor(path_str).await
+    } else {
+        Ok(())
     }
+}
 
-    fn render_variables(&self) -> Object {
-        let vars = self.render_var.clone().unwrap_or_default();
-        let mut map = HashMap::new();
+async fn create_project<T: AsRef<Path> + Debug>(
+    name: &str,
+    path: T,
+    config: &Config,
+) -> Result<()> {
+    let template_option = match config.template.as_deref() {
+        Some(t) => t,
+        None if config.extension => extensions::DEFAULT_TEMPLATE_URL,
+        None => functions::DEFAULT_TEMPLATE_URL,
+    };
+
+    let template_source = TemplateSource::try_from(template_option)?;
+    let template_path = template_source.expand().await?;
+
+    let parser = ParserBuilder::with_stdlib().build().into_diagnostic()?;
+
+    let template_vars = if config.extension {
+        config.extension_options.variables()?
+    } else {
+        config.function_options.variables(name, &config.bin_name)?
+    };
+
+    let mut globals = liquid::object!({
+        "project_name": name,
+        "binary_name": config.bin_name,
+    });
+    globals.extend(template_vars);
+    globals.extend(render_variables(config));
+    tracing::debug!(variables = ?globals, "rendering templates");
+
+    let render_dir = tempfile::tempdir().into_diagnostic()?;
+    let render_path = render_dir.path();
+
+    let walk_dir = WalkDir::new(&template_path).follow_links(false);
+    for entry in walk_dir {
+        let entry = entry.into_diagnostic()?;
+        let entry_path = entry.path();
+
+        let entry_name = entry_path
+            .file_name()
+            .ok_or_else(|| CreateError::InvalidTemplateEntry(entry_path.to_path_buf()))?;
+
+        if entry_path.is_dir() {
+            if entry_name != ".git" {
+                create_dir_all(entry_path)
+                    .into_diagnostic()
+                    .wrap_err_with(|| format!("unable to create directory: {:?}", entry_path))?;
+            }
+        } else if entry_name == "cargo-lambda-template.zip" {
+            continue;
+        } else {
+            let relative = entry_path.strip_prefix(&template_path).into_diagnostic()?;
+
+            let new_path = render_path.join(relative);
+            let parent_name = if let Some(parent) = new_path.parent() {
+                create_dir_all(parent).into_diagnostic()?;
+                parent.file_name().and_then(|p| p.to_str())
+            } else {
+                None
+            };
 
-        for var in vars {
-            let mut split = var.splitn(2, '=');
-            if let (Some(k), Some(v)) = (split.next(), split.next()) {
-                map.insert(k.to_string(), v.to_string());
+            if entry_name == "LICENSE" || is_ignore_file(config, relative) {
+                continue;
             }
-        }
 
-        let mut object = Object::new();
-        for (k, v) in map {
-            object.insert(k.into(), Value::scalar(v));
+            if entry_name == "Cargo.toml"
+                || entry_name == "README.md"
+                || (entry_name == "main.rs" && parent_name == Some("src"))
+                || (entry_name == "lib.rs" && parent_name == Some("src"))
+                || parent_name == Some("bin")
+                || is_render_file(config, relative)
+            {
+                let template = parser.parse_file(entry_path).into_diagnostic()?;
+
+                let mut file = File::create(&new_path)
+                    .into_diagnostic()
+                    .wrap_err_with(|| format!("unable to create file: {:?}", new_path))?;
+
+                template
+                    .render_to(&mut file, &globals)
+                    .into_diagnostic()
+                    .wrap_err_with(|| format!("failed to render template file: {:?}", &new_path))?;
+            } else {
+                copy_file(entry_path, &new_path)
+                    .into_diagnostic()
+                    .wrap_err_with(|| {
+                        format!(
+                            "failed to copy file: from {:?} to {:?}",
+                            &entry_path, &new_path
+                        )
+                    })?;
+            }
         }
-
-        object
     }
 
-    fn is_ignore_file(&self, path: &Path) -> bool {
-        self.ignore_file
-            .as_ref()
-            .map(|v| v.contains(&path.to_path_buf()))
-            .unwrap_or(false)
-    }
+    rename(render_path, &path)
+        .into_diagnostic()
+        .wrap_err_with(|| {
+            format!(
+                "failed to move package: from {:?} to {:?}",
+                &render_path, path
+            )
+        })
 }
 
 pub(crate) fn validate_name(name: &str) -> Result<()> {
@@ -264,3 +276,48 @@ pub(crate) fn validate_name(name: &str) -> Result<()> {
         false => Err(CreateError::InvalidPackageName(name.to_string()).into()),
     }
 }
+
+async fn open_code_editor(path: &str) -> Result<()> {
+    let editor = env::var("EDITOR").unwrap_or_default();
+    let editor = editor.trim();
+    if editor.is_empty() {
+        Err(CreateError::InvalidEditor(path.into()).into())
+    } else {
+        silent_command(editor.trim(), &[path]).await
+    }
+}
+
+fn is_render_file(config: &Config, path: &Path) -> bool {
+    config
+        .render_file
+        .as_ref()
+        .map(|v| v.contains(&path.to_path_buf()))
+        .unwrap_or(false)
+}
+
+fn render_variables(config: &Config) -> Object {
+    let vars = config.render_var.clone().unwrap_or_default();
+    let mut map = HashMap::new();
+
+    for var in vars {
+        let mut split = var.splitn(2, '=');
+        if let (Some(k), Some(v)) = (split.next(), split.next()) {
+            map.insert(k.to_string(), v.to_string());
+        }
+    }
+
+    let mut object = Object::new();
+    for (k, v) in map {
+        object.insert(k.into(), Value::scalar(v));
+    }
+
+    object
+}
+
+fn is_ignore_file(config: &Config, path: &Path) -> bool {
+    config
+        .ignore_file
+        .as_ref()
+        .map(|v| v.contains(&path.to_path_buf()))
+        .unwrap_or(false)
+}
diff --git a/crates/cargo-lambda-watch/Cargo.toml b/crates/cargo-lambda-watch/Cargo.toml
index 91c27628..ce4fb1e9 100644
--- a/crates/cargo-lambda-watch/Cargo.toml
+++ b/crates/cargo-lambda-watch/Cargo.toml
@@ -24,7 +24,7 @@ cargo-lambda-invoke.workspace = true
 cargo-lambda-metadata.workspace = true
 chrono = "0.4.19"
 clap.workspace = true
-dunce = "1.0.3"
+dunce.workspace = true
 http-api-problem = { version = "0.51.0", features = ["api-error", "hyper"] }
 hyper = "0.14.18"
 ignore-files = "1.0.1"
@@ -42,7 +42,7 @@ tower-http = { version = "0.3.3", features = [
     "catch-panic",
     "request-id",
     "trace",
-    "cors"
+    "cors",
 ] }
 tracing.workspace = true
 tracing-opentelemetry = "0.17.2"


Test patch:
diff --git a/crates/cargo-lambda-cli/tests/harness/mod.rs b/crates/cargo-lambda-cli/tests/harness/mod.rs
index 3a2f9569..8f94a371 100644
--- a/crates/cargo-lambda-cli/tests/harness/mod.rs
+++ b/crates/cargo-lambda-cli/tests/harness/mod.rs
@@ -1,4 +1,7 @@
-use std::path::{Path, PathBuf};
+use std::{
+    fs,
+    path::{Path, PathBuf},
+};
 
 use cargo_test_support::{paths::CargoPathExt, Project};
 use snapbox::cmd::Command;
@@ -28,6 +31,22 @@ pub fn cargo_lambda_new(project_name: &str) -> (PathBuf, Command) {
     (project_path, cmd)
 }
 
+pub fn cargo_lambda_init(project_name: &str) -> (PathBuf, Command) {
+    let project = project();
+
+    let cwd = dunce::canonicalize(project.root()).expect("failed to create canonical path");
+    fs::create_dir_all(&cwd).expect("failed to create project directory");
+
+    let cmd = Command::cargo_lambda()
+        .arg("lambda")
+        .arg("init")
+        .arg("--name")
+        .arg(project_name)
+        .current_dir(cwd);
+
+    (project.root(), cmd)
+}
+
 pub fn cargo_lambda_build<P: AsRef<Path>>(path: P) -> Command {
     Command::cargo_lambda()
         .arg("lambda")
diff --git a/crates/cargo-lambda-cli/tests/lambda_build.rs b/crates/cargo-lambda-cli/tests/lambda_build.rs
index 17c57482..0cc94b73 100644
--- a/crates/cargo-lambda-cli/tests/lambda_build.rs
+++ b/crates/cargo-lambda-cli/tests/lambda_build.rs
@@ -1,7 +1,9 @@
 use cargo_test_macro::cargo_test;
 
 mod harness;
-use harness::{cargo_lambda_build, cargo_lambda_new, test_project, LambdaProjectExt};
+use harness::{
+    cargo_lambda_build, cargo_lambda_init, cargo_lambda_new, test_project, LambdaProjectExt,
+};
 
 #[cargo_test]
 fn test_lambda_build() {
@@ -10,6 +12,7 @@ fn test_lambda_build() {
     test_build_basic_extension();
     test_build_logs_extension();
     test_build_telemetry_extension();
+    test_init_subcommand();
 }
 
 fn test_build_basic_function() {
@@ -98,3 +101,20 @@ fn test_build_telemetry_extension() {
     let bin = project.lambda_extension_bin("test-telemetry-extension");
     assert!(bin.exists(), "{:?} doesn't exist", bin);
 }
+
+fn test_init_subcommand() {
+    let (root, cmd) = cargo_lambda_init("test-basic-function");
+
+    cmd.arg("--no-interactive").assert().success();
+    assert!(root.join("Cargo.toml").exists(), "missing Cargo.toml");
+    assert!(
+        root.join("src").join("main.rs").exists(),
+        "missing src/main.rs"
+    );
+
+    let project = test_project(root);
+    cargo_lambda_build(project.root()).assert().success();
+
+    let bin = project.lambda_function_bin("test-basic-function");
+    assert!(bin.exists(), "{:?} doesn't exist", bin);
+}