From 5fd5c8db4c0237d98b3dde94b10b02f6df0a62be 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 | 167 ++++++++++++++++++++- cargo-shuttle/Cargo.toml | 3 + cargo-shuttle/src/args.rs | 109 +++++++++++++- cargo-shuttle/src/init.rs | 188 +++++++++-------------- cargo-shuttle/src/lib.rs | 130 ++++++++++++---- cargo-shuttle/tests/integration/init.rs | 190 ++++++++++++++---------- 6 files changed, 555 insertions(+), 232 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bdc080d3bb..3b95dceefe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,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" @@ -1226,6 +1240,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" @@ -1416,6 +1442,7 @@ name = "cargo-shuttle" version = "0.7.2" dependencies = [ "anyhow", + "assert_cmd", "async-trait", "bollard", "cargo", @@ -1426,6 +1453,7 @@ dependencies = [ "clap_complete", "crossbeam-channel", "crossterm", + "dialoguer", "dirs", "flate2", "futures", @@ -1436,6 +1464,7 @@ dependencies = [ "reqwest", "reqwest-middleware", "reqwest-retry", + "rexpect", "serde", "serde_json", "shuttle-common", @@ -1687,6 +1716,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" @@ -1731,6 +1766,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", + "unicode-width", + "winapi", +] + [[package]] name = "const_fn" version = "0.4.9" @@ -2223,12 +2272,30 @@ dependencies = [ "syn 1.0.99", ] +[[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" @@ -2329,6 +2396,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" @@ -2646,6 +2719,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" @@ -2761,7 +2843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a1e17342619edbc21a964c2afbeb6c820c6a2560032872f397bb97ea127bd0a" dependencies = [ "aho-corasick", - "bstr", + "bstr 0.2.17", "fnv", "log", "regex", @@ -3405,9 +3487,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.126" +version = "0.2.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" +checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" [[package]] name = "libgit2-sys" @@ -3830,6 +3912,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" @@ -3917,7 +4013,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea3ebcd72a54701f56345f16785a6d3ac2df7e986d273eb4395c0b01db17952" dependencies = [ - "bstr", + "bstr 0.2.17", "winapi", ] @@ -4362,6 +4458,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.2.1" @@ -4958,6 +5081,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" @@ -6431,6 +6567,12 @@ dependencies = [ "winapi", ] +[[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" @@ -6473,18 +6615,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.32" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994" +checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.32" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21" +checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" dependencies = [ "proc-macro2 1.0.43", "quote 1.0.21", @@ -7427,6 +7569,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 6f46127ad5..125a55af12 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.24" futures = "0.3.23" @@ -56,6 +57,8 @@ path = "../service" features = ["loader"] [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 c3f4ce2e1d..b6ced4e7cf 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)] @@ -176,6 +178,11 @@ pub struct InitArgs { /// Initialize with thruster framework #[clap(long, conflicts_with_all = &["actix-web","axum", "rocket", "tide", "tower", "poem", "warp", "salvo", "serenity"])] pub thruster: 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 +191,34 @@ 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 { + None + } + } +} + // Helper function to parse and return the absolute path fn parse_path(path: &OsStr) -> Result { canonicalize(path).map_err(|e| { @@ -201,3 +236,75 @@ fn parse_init_path(path: &OsStr) -> Result { parse_path(path) } + +#[cfg(test)] +mod 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, + 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, + _ => unreachable!(), + } + + init_args + } + + #[test] + fn test_init_args_framework() { + let framework_strs = vec![ + "actix-web", + "axum", + "rocket", + "tide", + "tower", + "poem", + "salvo", + "serenity", + "warp", + "thruster", + ]; + let frameworks: Vec = vec![ + Framework::ActixWeb, + Framework::Axum, + Framework::Rocket, + Framework::Tide, + Framework::Tower, + Framework::Poem, + Framework::Salvo, + Framework::Serenity, + Framework::Warp, + Framework::Thruster, + ]; + + for (framework_str, expected_framework) in framework_strs.into_iter().zip(frameworks) { + let framework = init_args_factory(framework_str).framework(); + assert_eq!(framework, Some(expected_framework)); + } + } +} diff --git a/cargo-shuttle/src/init.rs b/cargo-shuttle/src/init.rs index 0a27c21e8b..f68bfb2fc8 100644 --- a/cargo-shuttle/src/init.rs +++ b/cargo-shuttle/src/init.rs @@ -1,8 +1,8 @@ +use std::fmt; 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 +10,70 @@ use indoc::indoc; use toml_edit::{value, Array, Document, Item, Table}; use url::Url; +pub const FRAMEWORKS: [Framework; 10] = [ + Framework::ActixWeb, + Framework::Axum, + Framework::Rocket, + Framework::Tide, + Framework::Tower, + Framework::Poem, + Framework::Salvo, + Framework::Serenity, + Framework::Warp, + Framework::Thruster, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Framework { + ActixWeb, + Axum, + Rocket, + Tide, + Tower, + Poem, + Salvo, + Serenity, + Warp, + Thruster, +} + +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), + } + } +} + +impl fmt::Display for Framework { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Framework::ActixWeb => "actix-web", + Framework::Axum => "axum", + Framework::Rocket => "rocket", + Framework::Tide => "tide", + Framework::Tower => "tower", + Framework::Poem => "poem", + Framework::Salvo => "salvo", + Framework::Serenity => "serenity", + Framework::Warp => "warp", + Framework::Thruster => "thruster", + }) + } +} + pub trait ShuttleInit { fn set_cargo_dependencies( &self, @@ -597,52 +661,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 +676,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 +708,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 +726,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 +808,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 +825,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 53838c344b..804fb1e4fa 100644 --- a/cargo-shuttle/src/lib.rs +++ b/cargo-shuttle/src/lib.rs @@ -6,15 +6,14 @@ mod init; use std::collections::BTreeMap; 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 +22,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; @@ -38,6 +38,7 @@ use uuid::Uuid; use crate::args::{DeploymentCommand, ProjectCommand}; use crate::client::Client; +use crate::init::FRAMEWORKS; pub struct Shuttle { ctx: RequestContext, @@ -65,14 +66,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,14 +104,91 @@ 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!(); + PathBuf::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 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)?; + // 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 +211,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 f52dd35c12..e2612b9352 100644 --- a/cargo-shuttle/tests/integration/init.rs +++ b/cargo-shuttle/tests/integration/init.rs @@ -1,101 +1,135 @@ -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 -} +#[tokio::test] +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(); -/// 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 + let args = Args::parse_from([ + "cargo-shuttle", + "--api-url", + "http://shuttle.invalid:80", + "init", + "--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"); } -#[tokio::test] -async fn basic_init() { - let temp_dir = Builder::new().prefix("basic-init").tempdir().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(); - cargo_shuttle_init(temp_dir_path.clone()).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 = ")); + 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"]); + 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(()) } -#[tokio::test] -async fn framework_init() { +#[test] +fn interactive_rocket_init_no_framework_prompt() -> Result<(), Box> { 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 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", "--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(()) +} - let cargo_toml = read_to_string(temp_dir_path.join("Cargo.toml")).unwrap(); +#[test] +fn interactive_rocket_init_no_name_prompt() -> 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", + "--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;