From 233ef93fcc30b97d160bf026da22f6b01df3f957 Mon Sep 17 00:00:00 2001 From: Stijn Seghers Date: Tue, 29 Nov 2022 18:32:24 +0100 Subject: [PATCH] feat: interactive project initialization --- Cargo.lock | 179 ++++++++++++++++++- cargo-shuttle/Cargo.toml | 4 + cargo-shuttle/src/args.rs | 115 ++++++++++-- cargo-shuttle/src/init.rs | 160 +++++------------ cargo-shuttle/src/lib.rs | 133 +++++++++++--- cargo-shuttle/tests/integration/init.rs | 224 ++++++++++++++++-------- 6 files changed, 581 insertions(+), 234 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ee00f1f0..7b0b02596 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,6 +362,20 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "assert_cmd" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba45b8163c49ab5f972e59a8a5a03b6d2972619d486e19ec9fe744f7c2753d3c" +dependencies = [ + "bstr 1.0.1", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-channel" version = "1.6.1" @@ -1199,6 +1213,18 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "bstr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca0852af221f458706eb0725c03e4ed6c46af9ac98e6a689d5e634215d594dd" +dependencies = [ + "memchr", + "once_cell", + "regex-automata", + "serde", +] + [[package]] name = "buf_redux" version = "0.8.4" @@ -1389,6 +1415,7 @@ name = "cargo-shuttle" version = "0.7.2" dependencies = [ "anyhow", + "assert_cmd", "async-trait", "bollard", "cargo", @@ -1399,22 +1426,26 @@ dependencies = [ "clap_complete", "crossbeam-channel", "crossterm", + "dialoguer", "dirs", "flate2", "futures", "headers", "indoc", "log", + "openssl", "portpicker", "reqwest", "reqwest-middleware", "reqwest-retry", + "rexpect", "serde", "serde_json", "shuttle-common", "shuttle-secrets", "shuttle-service", "sqlx", + "strum", "tar", "tempfile", "test-context", @@ -1563,7 +1594,7 @@ dependencies = [ "once_cell", "strsim", "termcolor", - "terminal_size", + "terminal_size 0.2.2", ] [[package]] @@ -1661,6 +1692,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "comma" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" + [[package]] name = "commoncrypto" version = "0.2.0" @@ -1705,6 +1742,20 @@ dependencies = [ "cache-padded", ] +[[package]] +name = "console" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c050367d967ced717c04b65d8c619d863ef9292ce0c5760028655a2fb298718c" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "terminal_size 0.1.17", + "unicode-width", + "winapi", +] + [[package]] name = "const_fn" version = "0.4.9" @@ -2190,12 +2241,30 @@ dependencies = [ "syn 1.0.104", ] +[[package]] +name = "dialoguer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92e7e37ecef6857fdc0c0c5d42fd5b0938e46590c2183cc92dd310a6d078eb1" +dependencies = [ + "console", + "fuzzy-matcher", + "tempfile", + "zeroize", +] + [[package]] name = "diff" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -2290,6 +2359,12 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.31" @@ -2627,6 +2702,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "fwdansi" version = "1.1.0" @@ -2742,7 +2826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a1e17342619edbc21a964c2afbeb6c820c6a2560032872f397bb97ea127bd0a" dependencies = [ "aho-corasick", - "bstr", + "bstr 0.2.17", "fnv", "log", "regex", @@ -3795,6 +3879,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "nix" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e322c04a9e3440c327fca7b6c8a63e6890a32fa2ad689db972425f07e0d22abb" +dependencies = [ + "autocfg 1.1.0", + "bitflags", + "cfg-if 1.0.0", + "libc", + "memoffset", + "pin-utils", +] + [[package]] name = "nom" version = "7.1.1" @@ -3880,7 +3978,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea3ebcd72a54701f56345f16785a6d3ac2df7e986d273eb4395c0b01db17952" dependencies = [ - "bstr", + "bstr 0.2.17", "winapi", ] @@ -3916,6 +4014,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "openssl-src" +version = "111.24.0+1.1.1s" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3498f259dab01178c6228c6b00dcef0ed2a2d5e20d648c017861227773ea4abd" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.74" @@ -3925,6 +4032,7 @@ dependencies = [ "autocfg 1.1.0", "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -4330,6 +4438,33 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +[[package]] +name = "predicates" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed6bd09a7f7e68f3f0bf710fb7ab9c4615a488b58b5f653382a687701e458c92" +dependencies = [ + "difflib", + "itertools", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f883590242d3c6fc5bf50299011695fa6590c2c70eac95ee1bdb9a733ad1a2" + +[[package]] +name = "predicates-tree" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ff541861505aabf6ea722d2131ee980b8276e10a1297b94e896dd8b621850d" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.3.0" @@ -4936,6 +5071,19 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "rexpect" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01ff60778f96fb5a48adbe421d21bf6578ed58c0872d712e7e08593c195adff8" +dependencies = [ + "comma", + "nix", + "regex", + "tempfile", + "thiserror", +] + [[package]] name = "rfc7239" version = "0.1.0" @@ -6308,6 +6456,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "terminal_size" version = "0.2.2" @@ -6318,6 +6476,12 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "termtree" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95059e91184749cb66be6dc994f67f182b6d897cb3df74a5bf66b5e709295fd8" + [[package]] name = "test-context" version = "0.1.4" @@ -7271,6 +7435,15 @@ dependencies = [ "quote 1.0.21", ] +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "waker-fn" version = "1.1.0" diff --git a/cargo-shuttle/Cargo.toml b/cargo-shuttle/Cargo.toml index 6648ac562..dff9b280d 100644 --- a/cargo-shuttle/Cargo.toml +++ b/cargo-shuttle/Cargo.toml @@ -18,6 +18,7 @@ clap = { version = "3.2.17", features = ["derive", "env"] } clap_complete = "3.2.5" crossbeam-channel = "0.5.6" crossterm = "0.25.0" +dialoguer = { version = "0.10.2", features = ["fuzzy-select"] } dirs = "4.0.0" flate2 = "1.0.25" futures = "0.3.25" @@ -32,6 +33,7 @@ reqwest-retry = "0.2.0" serde = { version = "1.0.148", features = ["derive"] } serde_json = "1.0.89" sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres"] } +strum = { version = "0.24.1", features = ["derive"] } tar = "0.4.38" tokio = { version = "1.22.0", features = ["macros"] } tokio-tungstenite = { version = "0.17.2", features = ["native-tls"] } @@ -60,6 +62,8 @@ features = ["loader"] vendored-openssl = ["openssl/vendored"] [dev-dependencies] +assert_cmd = "2.0.6" +rexpect = "0.5.0" tempfile = "3.3.0" test-context = "0.1.4" # Tmp until this branch is merged and released diff --git a/cargo-shuttle/src/args.rs b/cargo-shuttle/src/args.rs index c3f4ce2e1..d096914e7 100644 --- a/cargo-shuttle/src/args.rs +++ b/cargo-shuttle/src/args.rs @@ -10,6 +10,8 @@ use clap_complete::Shell; use shuttle_common::project::ProjectName; use uuid::Uuid; +use crate::init::Framework; + #[derive(Parser)] #[clap( version, @@ -113,7 +115,7 @@ pub enum ProjectCommand { Status, } -#[derive(Parser)] +#[derive(Parser, Clone, Debug)] pub struct LoginArgs { /// api key for the shuttle platform #[clap(long)] @@ -147,35 +149,43 @@ pub struct RunArgs { #[derive(Parser, Debug)] pub struct InitArgs { /// Initialize with actix-web framework - #[clap(long="actix-web", conflicts_with_all = &["axum", "rocket", "tide", "tower", "poem", "serenity", "warp", "salvo", "thruster"])] + #[clap(long="actix-web", conflicts_with_all = &["axum", "rocket", "tide", "tower", "poem", "serenity", "warp", "salvo", "thruster", "no-framework"])] pub actix_web: bool, /// Initialize with axum framework - #[clap(long, conflicts_with_all = &["actix-web","rocket", "tide", "tower", "poem", "serenity", "warp", "salvo", "thruster"])] + #[clap(long, conflicts_with_all = &["actix-web","rocket", "tide", "tower", "poem", "serenity", "warp", "salvo", "thruster", "no-framework"])] pub axum: bool, /// Initialize with rocket framework - #[clap(long, conflicts_with_all = &["actix-web","axum", "tide", "tower", "poem", "serenity", "warp", "salvo", "thruster"])] + #[clap(long, conflicts_with_all = &["actix-web","axum", "tide", "tower", "poem", "serenity", "warp", "salvo", "thruster", "no-framework"])] pub rocket: bool, /// Initialize with tide framework - #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tower", "poem", "serenity", "warp", "salvo", "thruster"])] + #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tower", "poem", "serenity", "warp", "salvo", "thruster", "no-framework"])] pub tide: bool, /// Initialize with tower framework - #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "poem", "serenity", "warp", "salvo", "thruster"])] + #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "poem", "serenity", "warp", "salvo", "thruster", "no-framework"])] pub tower: bool, /// Initialize with poem framework - #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "serenity", "warp", "salvo", "thruster"])] + #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "serenity", "warp", "salvo", "thruster", "no-framework"])] pub poem: bool, /// Initialize with salvo framework - #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "warp", "serenity", "thruster"])] + #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "warp", "serenity", "thruster", "no-framework"])] pub salvo: bool, /// Initialize with serenity framework - #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "warp", "salvo", "thruster"])] + #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "warp", "salvo", "thruster", "no-framework"])] pub serenity: bool, /// Initialize with warp framework - #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "serenity", "salvo", "thruster"])] + #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "serenity", "salvo", "thruster", "no-framework"])] pub warp: bool, /// Initialize with thruster framework - #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "warp", "salvo", "serenity"])] + #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "warp", "salvo", "serenity", "no-framework"])] pub thruster: bool, + /// Initialize without a framework + #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "warp", "salvo", "serenity", "thruster"])] + pub no_framework: bool, + /// Whether to create the environment for this project on Shuttle + #[clap(long)] + pub new: bool, + #[clap(flatten)] + pub login_args: LoginArgs, /// Path to initialize a new shuttle project #[clap( parse(try_from_os_str = parse_init_path), @@ -184,6 +194,36 @@ pub struct InitArgs { pub path: PathBuf, } +impl InitArgs { + pub fn framework(&self) -> Option { + if self.actix_web { + Some(Framework::ActixWeb) + } else if self.axum { + Some(Framework::Axum) + } else if self.rocket { + Some(Framework::Rocket) + } else if self.tide { + Some(Framework::Tide) + } else if self.tower { + Some(Framework::Tower) + } else if self.poem { + Some(Framework::Poem) + } else if self.salvo { + Some(Framework::Salvo) + } else if self.serenity { + Some(Framework::Serenity) + } else if self.warp { + Some(Framework::Warp) + } else if self.thruster { + Some(Framework::Thruster) + } else if self.no_framework { + Some(Framework::None) + } else { + None + } + } +} + // Helper function to parse and return the absolute path fn parse_path(path: &OsStr) -> Result { canonicalize(path).map_err(|e| { @@ -195,9 +235,60 @@ fn parse_path(path: &OsStr) -> Result { } // Helper function to parse, create if not exists, and return the absolute path -fn parse_init_path(path: &OsStr) -> Result { +pub(crate) fn parse_init_path(path: &OsStr) -> Result { // Create the directory if does not exist create_dir_all(path)?; parse_path(path) } + +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use super::*; + + fn init_args_factory(framework: &str) -> InitArgs { + let mut init_args = InitArgs { + actix_web: false, + axum: false, + rocket: false, + tide: false, + tower: false, + poem: false, + salvo: false, + serenity: false, + warp: false, + thruster: false, + no_framework: false, + new: false, + login_args: LoginArgs { api_key: None }, + path: PathBuf::new(), + }; + + match framework { + "actix-web" => init_args.actix_web = true, + "axum" => init_args.axum = true, + "rocket" => init_args.rocket = true, + "tide" => init_args.tide = true, + "tower" => init_args.tower = true, + "poem" => init_args.poem = true, + "salvo" => init_args.salvo = true, + "serenity" => init_args.serenity = true, + "warp" => init_args.warp = true, + "thruster" => init_args.thruster = true, + "none" => init_args.no_framework = true, + _ => unreachable!(), + } + + init_args + } + + #[test] + fn test_init_args_framework() { + for framework in Framework::iter() { + let args = init_args_factory(&framework.to_string()); + assert_eq!(args.framework(), Some(framework)); + } + } +} diff --git a/cargo-shuttle/src/init.rs b/cargo-shuttle/src/init.rs index 0a27c21e8..524864bea 100644 --- a/cargo-shuttle/src/init.rs +++ b/cargo-shuttle/src/init.rs @@ -2,7 +2,6 @@ use std::fs::{read_to_string, File}; use std::io::Write; use std::path::{Path, PathBuf}; -use crate::args::InitArgs; use anyhow::Result; use cargo::ops::NewOptions; use cargo_edit::{find, get_latest_dependency, registry_url}; @@ -10,6 +9,43 @@ use indoc::indoc; use toml_edit::{value, Array, Document, Item, Table}; use url::Url; +#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display, strum::EnumIter)] +#[strum(serialize_all = "kebab-case")] +pub enum Framework { + ActixWeb, + Axum, + Rocket, + Tide, + Tower, + Poem, + Salvo, + Serenity, + Warp, + Thruster, + None, +} + +impl Framework { + /// Returns a framework-specific struct that implements the trait `ShuttleInit` + /// for writing framework-specific dependencies to `Cargo.toml` and generating + /// boilerplate code in `src/lib.rs`. + pub fn init_config(&self) -> Box { + match self { + Framework::ActixWeb => Box::new(ShuttleInitActixWeb), + Framework::Axum => Box::new(ShuttleInitAxum), + Framework::Rocket => Box::new(ShuttleInitRocket), + Framework::Tide => Box::new(ShuttleInitTide), + Framework::Tower => Box::new(ShuttleInitTower), + Framework::Poem => Box::new(ShuttleInitPoem), + Framework::Salvo => Box::new(ShuttleInitSalvo), + Framework::Serenity => Box::new(ShuttleInitSerenity), + Framework::Warp => Box::new(ShuttleInitWarp), + Framework::Thruster => Box::new(ShuttleInitThruster), + Framework::None => Box::new(ShuttleInitNoOp), + } + } +} + pub trait ShuttleInit { fn set_cargo_dependencies( &self, @@ -597,52 +633,6 @@ impl ShuttleInit for ShuttleInitNoOp { } } -/// Returns a framework-specific struct that implements the trait `ShuttleInit` -/// for writing framework-specific dependencies to `Cargo.toml` and generating -/// boilerplate code in `src/lib.rs`. -pub fn get_framework(init_args: &InitArgs) -> Box { - if init_args.actix_web { - return Box::new(ShuttleInitActixWeb); - } - if init_args.axum { - return Box::new(ShuttleInitAxum); - } - - if init_args.rocket { - return Box::new(ShuttleInitRocket); - } - - if init_args.tide { - return Box::new(ShuttleInitTide); - } - - if init_args.tower { - return Box::new(ShuttleInitTower); - } - - if init_args.poem { - return Box::new(ShuttleInitPoem); - } - - if init_args.salvo { - return Box::new(ShuttleInitSalvo); - } - - if init_args.serenity { - return Box::new(ShuttleInitSerenity); - } - - if init_args.warp { - return Box::new(ShuttleInitWarp); - } - - if init_args.thruster { - return Box::new(ShuttleInitThruster); - } - - Box::new(ShuttleInitNoOp) -} - /// Interoprates with `cargo` crate and calls `cargo init --libs [path]`. pub fn cargo_init(path: PathBuf) -> Result<()> { let opts = NewOptions::new(None, false, true, path, None, None, None)?; @@ -658,7 +648,7 @@ pub fn cargo_init(path: PathBuf) -> Result<()> { } /// Performs shuttle init on the existing files generated by `cargo init --libs [path]`. -pub fn cargo_shuttle_init(path: PathBuf, framework: Box) -> Result<()> { +pub fn cargo_shuttle_init(path: PathBuf, framework: Framework) -> Result<()> { let cargo_toml_path = path.join("Cargo.toml"); let mut cargo_doc = read_to_string(cargo_toml_path.clone()) .unwrap() @@ -690,8 +680,10 @@ pub fn cargo_shuttle_init(path: PathBuf, framework: Box) -> Res get_latest_dependency_version, ); + let init_config = framework.init_config(); + // Set framework-specific dependencies to the `dependencies` table - framework.set_cargo_dependencies( + init_config.set_cargo_dependencies( &mut dependencies, &manifest_path, &url, @@ -706,7 +698,7 @@ pub fn cargo_shuttle_init(path: PathBuf, framework: Box) -> Res // Write boilerplate to `src/lib.rs` file let lib_path = path.join("src").join("lib.rs"); - let boilerplate = framework.get_boilerplate_code_for_framework(); + let boilerplate = init_config.get_boilerplate_code_for_framework(); if !boilerplate.is_empty() { write_lib_file(boilerplate, &lib_path)?; } @@ -788,38 +780,6 @@ pub fn write_lib_file(boilerplate: &'static str, lib_path: &Path) -> Result<()> mod shuttle_init_tests { use super::*; - fn init_args_factory(framework: &str) -> InitArgs { - let mut init_args = InitArgs { - actix_web: false, - axum: false, - rocket: false, - tide: false, - tower: false, - poem: false, - salvo: false, - serenity: false, - warp: false, - thruster: false, - path: PathBuf::new(), - }; - - match framework { - "actix-web" => init_args.actix_web = true, - "axum" => init_args.axum = true, - "rocket" => init_args.rocket = true, - "tide" => init_args.tide = true, - "tower" => init_args.tower = true, - "poem" => init_args.poem = true, - "salvo" => init_args.salvo = true, - "serenity" => init_args.serenity = true, - "warp" => init_args.warp = true, - "thruster" => init_args.thruster = true, - _ => unreachable!(), - } - - init_args - } - fn cargo_toml_factory() -> Document { indoc! {r#" [dependencies] @@ -837,42 +797,6 @@ mod shuttle_init_tests { "1.0".to_string() } - #[test] - fn test_get_framework_via_get_boilerplate_code() { - let frameworks = vec![ - "actix-web", - "axum", - "rocket", - "tide", - "tower", - "poem", - "salvo", - "serenity", - "warp", - "thruster", - ]; - let framework_inits: Vec> = vec![ - Box::new(ShuttleInitActixWeb), - Box::new(ShuttleInitAxum), - Box::new(ShuttleInitRocket), - Box::new(ShuttleInitTide), - Box::new(ShuttleInitTower), - Box::new(ShuttleInitPoem), - Box::new(ShuttleInitSalvo), - Box::new(ShuttleInitSerenity), - Box::new(ShuttleInitWarp), - Box::new(ShuttleInitThruster), - ]; - - for (framework, expected_framework_init) in frameworks.into_iter().zip(framework_inits) { - let framework_init = get_framework(&init_args_factory(framework)); - assert_eq!( - framework_init.get_boilerplate_code_for_framework(), - expected_framework_init.get_boilerplate_code_for_framework(), - ); - } - } - #[test] fn test_set_inline_table_dependency_features() { let mut cargo_toml = cargo_toml_factory(); diff --git a/cargo-shuttle/src/lib.rs b/cargo-shuttle/src/lib.rs index be36fe97d..dca0207a6 100644 --- a/cargo-shuttle/src/lib.rs +++ b/cargo-shuttle/src/lib.rs @@ -5,16 +5,16 @@ mod factory; mod init; use std::collections::BTreeMap; +use std::ffi::OsString; use std::fs::{read_to_string, File}; -use std::io::Write; -use std::io::{self, stdout}; +use std::io::stdout; use std::net::{Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::rc::Rc; -use anyhow::{anyhow, Context, Result}; -pub use args::{Args, Command, DeployArgs, InitArgs, ProjectArgs, RunArgs}; -use args::{AuthArgs, LoginArgs}; +use anyhow::{anyhow, bail, Context, Result}; +use args::AuthArgs; +pub use args::{Args, Command, DeployArgs, InitArgs, LoginArgs, ProjectArgs, RunArgs}; use cargo::core::resolver::CliFeatures; use cargo::core::Workspace; use cargo::ops::{PackageOpts, Packages}; @@ -23,6 +23,7 @@ use clap::CommandFactory; use clap_complete::{generate, Shell}; use config::RequestContext; use crossterm::style::Stylize; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, Password}; use factory::LocalFactory; use flate2::read::GzDecoder; use flate2::write::GzEncoder; @@ -31,6 +32,7 @@ use futures::StreamExt; use shuttle_common::models::secret; use shuttle_service::loader::{build_crate, Loader}; use shuttle_service::Logger; +use strum::IntoEnumIterator; use tar::{Archive, Builder}; use tokio::sync::mpsc; use tracing::trace; @@ -65,14 +67,14 @@ impl Shuttle { self.load_project(&mut args.project_args)?; } + self.ctx.set_api_url(args.api_url); + match args.cmd { - Command::Init(init_args) => self.init(init_args).await, + Command::Init(init_args) => self.init(init_args, args.project_args).await, Command::Generate { shell, output } => self.complete(shell, output).await, Command::Login(login_args) => self.login(login_args).await, Command::Run(run_args) => self.local_run(run_args).await, need_client => { - self.ctx.set_api_url(args.api_url); - let mut client = Client::new(self.ctx.api_url()); client.set_api_key(self.ctx.api_key()?); @@ -103,13 +105,92 @@ impl Shuttle { .map(|_| CommandOutcome::Ok) } - async fn init(&self, args: InitArgs) -> Result<()> { - // Interface with cargo to initialize new lib package for shuttle - let path = args.path.clone(); - init::cargo_init(path.clone())?; + /// Log in, initialize a project and potentially create the Shuttle environment for it. + /// + /// If both a project name and framework are passed as arguments, it will run without any extra + /// interaction. + async fn init(&mut self, args: InitArgs, mut project_args: ProjectArgs) -> Result<()> { + let interactive = project_args.name.is_none() || args.framework().is_none(); + + let theme = ColorfulTheme::default(); + + // 1. Log in (if not logged in yet) + if self.ctx.api_key().is_err() { + if interactive { + println!("First, let's log in to your Shuttle account."); + self.login(args.login_args.clone()).await?; + println!(); + } else if args.new && args.login_args.api_key.is_some() { + self.login(args.login_args.clone()).await?; + } else { + bail!("Tried to login to create a Shuttle environment, but no API key was set.") + } + } - let framework = init::get_framework(&args); + // 2. Ask for project name + if project_args.name.is_none() { + println!("How do you want to name your project? It will be hosted at ${{project_name}}.shuttleapp.rs."); + // TODO: Check whether the project name is still available + project_args.name = Some( + Input::with_theme(&theme) + .with_prompt("Project name") + .interact()?, + ); + println!(); + } + + // 3. Confirm the project directory + let path = if interactive { + println!("Where should we create this project?"); + let directory_str: String = Input::with_theme(&theme) + .with_prompt("Directory") + .default(".".to_owned()) + .interact()?; + println!(); + args::parse_init_path(&OsString::from(directory_str))? + } else { + args.path.clone() + }; + + // 4. Ask for the framework + let framework = match args.framework() { + Some(framework) => framework, + None => { + println!( + "Shuttle works with a range of web frameworks. Which one do you want to use?" + ); + let frameworks = init::Framework::iter().collect::>(); + let index = FuzzySelect::with_theme(&theme) + .items(&frameworks) + .default(0) + .interact()?; + println!(); + frameworks[index] + } + }; + + // 5. Initialize locally + init::cargo_init(path.clone())?; init::cargo_shuttle_init(path, framework)?; + println!(); + + // 6. Confirm that the user wants to create the project environment on Shuttle + let should_create_environment = if !interactive { + args.new + } else if args.new { + true + } else { + Confirm::with_theme(&theme) + .with_prompt("Do you want to create the project environment on Shuttle?") + .default(true) + .interact()? + }; + if should_create_environment { + self.load_project(&mut project_args)?; + let mut client = Client::new(self.ctx.api_url()); + client.set_api_key(self.ctx.api_key()?); + self.project_create(&client).await?; + } Ok(()) } @@ -133,23 +214,21 @@ impl Shuttle { self.ctx.load_local(project_args) } + /// Log in with the given API key or after prompting the user for one. async fn login(&mut self, login_args: LoginArgs) -> Result<()> { - let api_key_str = login_args.api_key.unwrap_or_else(|| { - let url = "https://shuttle.rs/login"; + let api_key_str = match login_args.api_key { + Some(api_key) => api_key, + None => { + let url = "https://shuttle.rs/login"; + let _ = webbrowser::open(url); - let _ = webbrowser::open(url); + println!("If your browser did not automatically open, go to {url}"); - println!("If your browser did not automatically open, go to {url}"); - print!("Enter Api Key: "); - - stdout().flush().unwrap(); - - let mut input = String::new(); - - io::stdin().read_line(&mut input).unwrap(); - - input - }); + Password::with_theme(&ColorfulTheme::default()) + .with_prompt("API key") + .interact()? + } + }; let api_key = api_key_str.trim().parse()?; diff --git a/cargo-shuttle/tests/integration/init.rs b/cargo-shuttle/tests/integration/init.rs index f52dd35c1..45551314a 100644 --- a/cargo-shuttle/tests/integration/init.rs +++ b/cargo-shuttle/tests/integration/init.rs @@ -1,101 +1,177 @@ -use std::{ - fs::read_to_string, - path::{Path, PathBuf}, -}; +use std::fs::read_to_string; +use std::path::Path; +use std::process::Command; -use cargo_shuttle::{Args, Command, CommandOutcome, InitArgs, ProjectArgs, Shuttle}; +use cargo_shuttle::{Args, Shuttle}; +use clap::Parser; use indoc::indoc; use tempfile::Builder; -/// creates a `cargo-shuttle` init instance with some reasonable defaults set. -async fn cargo_shuttle_init(path: PathBuf) -> anyhow::Result { - let working_directory = Path::new(".").to_path_buf(); - - Shuttle::new() - .unwrap() - .run(Args { - api_url: Some("http://shuttle.invalid:80".to_string()), - project_args: ProjectArgs { - working_directory, - name: None, - }, - cmd: Command::Init(InitArgs { - actix_web: false, - axum: false, - rocket: false, - tide: false, - tower: false, - poem: false, - salvo: false, - serenity: false, - warp: false, - thruster: false, - path, - }), - }) - .await -} - -/// creates a `cargo-shuttle` init instance for initializing the `rocket` framework -async fn cargo_shuttle_init_framework(path: PathBuf) -> anyhow::Result { - let working_directory = Path::new(".").to_path_buf(); - - Shuttle::new() - .unwrap() - .run(Args { - api_url: Some("http://shuttle.invalid:80".to_string()), - project_args: ProjectArgs { - working_directory, - name: None, - }, - cmd: Command::Init(InitArgs { - actix_web: false, - axum: false, - rocket: true, - tide: false, - tower: false, - poem: false, - salvo: false, - serenity: false, - warp: false, - thruster: false, - path, - }), - }) - .await -} - #[tokio::test] -async fn basic_init() { +async fn non_interactive_basic_init() { let temp_dir = Builder::new().prefix("basic-init").tempdir().unwrap(); let temp_dir_path = temp_dir.path().to_owned(); - cargo_shuttle_init(temp_dir_path.clone()).await.unwrap(); - let cargo_toml = read_to_string(temp_dir_path.join("Cargo.toml")).unwrap(); + let args = Args::parse_from([ + "cargo-shuttle", + "--api-url", + "http://shuttle.invalid:80", + "init", + "--api-key", + "fake-api-key", + "--name", + "my-project", + "--no-framework", + temp_dir_path.to_str().unwrap(), + ]); + Shuttle::new().unwrap().run(args).await.unwrap(); + let cargo_toml = read_to_string(temp_dir_path.join("Cargo.toml")).unwrap(); // Expected: name = "basic-initRANDOM_CHARS" assert!(cargo_toml.contains("name = \"basic-init")); assert!(cargo_toml.contains("shuttle-service = { version = ")); } #[tokio::test] -async fn framework_init() { +async fn non_interactive_rocket_init() { let temp_dir = Builder::new().prefix("rocket-init").tempdir().unwrap(); let temp_dir_path = temp_dir.path().to_owned(); - cargo_shuttle_init_framework(temp_dir_path.clone()) - .await - .unwrap(); + let args = Args::parse_from([ + "cargo-shuttle", + "--api-url", + "http://shuttle.invalid:80", + "init", + "--api-key", + "fake-api-key", + "--name", + "my-project", + "--rocket", + temp_dir_path.to_str().unwrap(), + ]); + Shuttle::new().unwrap().run(args).await.unwrap(); + + assert_valid_rocket_project(temp_dir_path.as_path(), "rocket-init"); +} - let cargo_toml = read_to_string(temp_dir_path.join("Cargo.toml")).unwrap(); +#[test] +fn interactive_rocket_init() -> Result<(), Box> { + let temp_dir = Builder::new().prefix("rocket-init").tempdir().unwrap(); + let temp_dir_path = temp_dir.path().to_owned(); + + let bin_path = assert_cmd::cargo::cargo_bin("cargo-shuttle"); + let mut command = Command::new(bin_path); + command.args([ + "--api-url", + "http://shuttle.invalid:80", + "init", + "--api-key", + "fake-api-key", + ]); + let mut session = rexpect::session::spawn_command(command, Some(2000))?; + + session.exp_string( + "How do you want to name your project? It will be hosted at ${project_name}.shuttleapp.rs.", + )?; + session.exp_string("Project name")?; + session.send_line("my-project")?; + session.exp_string("Where should we create this project?")?; + session.exp_string("Directory")?; + session.send_line(temp_dir_path.to_str().unwrap())?; + session.exp_string( + "Shuttle works with a range of web frameworks. Which one do you want to use?", + )?; + // Partial input should be enough to match "rocket" + session.send_line("roc")?; + session.exp_string("Do you want to create the project environment on Shuttle?")?; + session.send("y")?; + session.flush()?; + session.exp_string("yes")?; + + assert_valid_rocket_project(temp_dir_path.as_path(), "rocket-init"); + + Ok(()) +} + +#[test] +fn interactive_rocket_init_dont_prompt_framework() -> Result<(), Box> { + let temp_dir = Builder::new().prefix("rocket-init").tempdir().unwrap(); + let temp_dir_path = temp_dir.path().to_owned(); + + let bin_path = assert_cmd::cargo::cargo_bin("cargo-shuttle"); + let mut command = Command::new(bin_path); + command.args([ + "--api-url", + "http://shuttle.invalid:80", + "init", + "--api-key", + "fake-api-key", + "--rocket", + ]); + let mut session = rexpect::session::spawn_command(command, Some(2000))?; + + session.exp_string( + "How do you want to name your project? It will be hosted at ${project_name}.shuttleapp.rs.", + )?; + session.exp_string("Project name")?; + session.send_line("my-project")?; + session.exp_string("Where should we create this project?")?; + session.exp_string("Directory")?; + session.send_line(temp_dir_path.to_str().unwrap())?; + session.exp_string("Do you want to create the project environment on Shuttle?")?; + session.send("y")?; + session.flush()?; + session.exp_string("yes")?; + + assert_valid_rocket_project(temp_dir_path.as_path(), "rocket-init"); + + Ok(()) +} + +#[test] +fn interactive_rocket_init_dont_prompt_name() -> Result<(), Box> { + let temp_dir = Builder::new().prefix("rocket-init").tempdir().unwrap(); + let temp_dir_path = temp_dir.path().to_owned(); + + let bin_path = assert_cmd::cargo::cargo_bin("cargo-shuttle"); + let mut command = Command::new(bin_path); + command.args([ + "--api-url", + "http://shuttle.invalid:80", + "init", + "--api-key", + "fake-api-key", + "--name", + "my-project", + ]); + let mut session = rexpect::session::spawn_command(command, Some(2000))?; + + session.exp_string("Where should we create this project?")?; + session.exp_string("Directory")?; + session.send_line(temp_dir_path.to_str().unwrap())?; + session.exp_string( + "Shuttle works with a range of web frameworks. Which one do you want to use?", + )?; + // Partial input should be enough to match "rocket" + session.send_line("roc")?; + session.exp_string("Do you want to create the project environment on Shuttle?")?; + session.send("y")?; + session.flush()?; + session.exp_string("yes")?; + + assert_valid_rocket_project(temp_dir_path.as_path(), "rocket-init"); + + Ok(()) +} - // Expected: name = "rocket-initRANDOM_CHARS" - assert!(cargo_toml.contains("name = \"rocket-init")); +fn assert_valid_rocket_project(path: &Path, name_prefix: &str) { + let cargo_toml = read_to_string(path.join("Cargo.toml")).unwrap(); + assert!(cargo_toml.contains(&format!("name = \"{name_prefix}"))); assert!(cargo_toml.contains("shuttle-service = { version = ")); assert!(cargo_toml.contains("features = [\"web-rocket\"]")); assert!(cargo_toml.contains("rocket = ")); - let lib_file = read_to_string(temp_dir_path.join("src").join("lib.rs")).unwrap(); + let lib_file = read_to_string(path.join("src").join("lib.rs")).unwrap(); let expected = indoc! {r#" #[macro_use] extern crate rocket;