diff --git a/Cargo.lock b/Cargo.lock index 47aa87159..051712669 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1003,7 +1003,7 @@ dependencies = [ "shuttle-common-tests", "shuttle-proto", "shuttle-service", - "strum 0.25.0", + "strum 0.26.1", "tar", "tempfile", "tokio", @@ -1498,9 +1498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ "console", - "fuzzy-matcher", "shell-words", - "tempfile", "thiserror", "zeroize", ] @@ -1874,15 +1872,6 @@ 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 = "generic-array" version = "0.14.7" @@ -5212,7 +5201,7 @@ dependencies = [ [[package]] name = "shuttle-common" -version = "0.40.2" +version = "0.40.3" dependencies = [ "anyhow", "async-trait", @@ -5243,7 +5232,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum 0.25.0", + "strum 0.26.1", "test-context 0.3.0", "thiserror", "tokio", @@ -5308,7 +5297,7 @@ dependencies = [ "shuttle-proto", "shuttle-service", "sqlx", - "strum 0.25.0", + "strum 0.26.1", "tar", "tempfile", "thiserror", @@ -5369,7 +5358,7 @@ dependencies = [ "shuttle-proto", "snailquote", "sqlx", - "strum 0.25.0", + "strum 0.26.1", "tar", "tempfile", "test-context 0.3.0", @@ -5478,7 +5467,7 @@ dependencies = [ "shuttle-common-tests", "shuttle-proto", "sqlx", - "strum 0.25.0", + "strum 0.26.1", "thiserror", "tokio", "tonic 0.10.2", @@ -5934,11 +5923,11 @@ checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" [[package]] name = "strum" -version = "0.25.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +checksum = "723b93e8addf9aa965ebe2d11da6d7540fa2283fcea14b3371ff055f7ba13f5f" dependencies = [ - "strum_macros 0.25.3", + "strum_macros 0.26.1", ] [[package]] @@ -5956,9 +5945,9 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.25.3" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index bb62eb231..d0ee0f76a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,8 +47,9 @@ bollard = "0.15.0" bytes = "1.3.0" cargo_metadata = "0.18.1" chrono = { version = "0.4.23", default-features = false } -colored = "2.0.0" clap = { version = "4.2.7", features = ["derive"] } +colored = "2.0.0" +comfy-table = "6.2.0" crossterm = "0.27.0" ctor = "0.2.5" dirs = "5.0.0" @@ -59,6 +60,7 @@ futures = "0.3.27" headers = "0.3.8" home = "0.5.4" http = "0.2.8" +http-body = "0.4.5" hyper = "0.14.23" # not great, but waiting for WebSocket changes to be merged hyper-reverse-proxy = { git = "https://github.com/chesedo/hyper-reverse-proxy", branch = "bug/host_header" } @@ -86,7 +88,7 @@ serde = { version = "1.0.148", default-features = false } serde_json = "1.0.89" sqlx = { version = "0.7.1", features = ["runtime-tokio", "tls-rustls"] } strfmt = "0.2.2" -strum = { version = "0.25.0", features = ["derive"] } +strum = { version = "0.26.1", features = ["derive"] } tar = "0.4.38" tempfile = "3.4.0" test-context = "0.3.0" @@ -108,5 +110,5 @@ ttl_cache = "0.5.1" ulid = "1.0.0" url = "2.4.0" uuid = "1.2.2" -wiremock = "0.6.0-rc.3" +wiremock = "0.6.0" zeroize = "1.6.0" diff --git a/cargo-shuttle/Cargo.toml b/cargo-shuttle/Cargo.toml index 4c55a238a..10c38a7f0 100644 --- a/cargo-shuttle/Cargo.toml +++ b/cargo-shuttle/Cargo.toml @@ -21,7 +21,7 @@ clap = { workspace = true, features = ["env"] } clap_complete = "4.3.1" clap_mangen = "0.2.15" crossterm = { workspace = true } -dialoguer = { version = "0.11.0", features = ["fuzzy-select"] } +dialoguer = { version = "0.11", default-features = false, features = ["password"] } dirs = { workspace = true } dunce = { workspace = true } flate2 = { workspace = true } diff --git a/cargo-shuttle/src/args.rs b/cargo-shuttle/src/args.rs index 5205f47ef..c571b5d7e 100644 --- a/cargo-shuttle/src/args.rs +++ b/cargo-shuttle/src/args.rs @@ -12,7 +12,7 @@ use clap::{ Parser, ValueEnum, }; use clap_complete::Shell; -use shuttle_common::constants::DEFAULT_IDLE_MINUTES; +use shuttle_common::constants::{DEFAULT_IDLE_MINUTES, EXAMPLES_REPO}; use shuttle_common::resource; use uuid::Uuid; @@ -33,6 +33,10 @@ pub struct ShuttleArgs { /// (allows targeting a custom deployed instance for this command only, mainly for development) #[arg(long, env = "SHUTTLE_API")] pub api_url: Option, + /// Disable network requests that are not strictly necessary. Limits some features. + #[arg(long, env = "SHUTTLE_OFFLINE")] + pub offline: bool, + #[command(subcommand)] pub cmd: Command, } @@ -320,43 +324,41 @@ pub struct InitArgs { /// Don't initialize a new git repository #[arg(long)] pub no_git: bool, + #[command(flatten)] pub login_args: LoginArgs, } -#[derive(ValueEnum, Clone, Debug, strum::Display, strum::EnumIter)] -#[strum(serialize_all = "kebab-case")] +#[derive(ValueEnum, Clone, Debug, strum::EnumMessage, strum::VariantArray)] pub enum InitTemplateArg { - /// Actix Web framework - ActixWeb, - /// Axum web framework + /// Axum - Modular web framework from the Tokio ecosystem Axum, - /// Loco web framework + /// Actix Web - Powerful and fast web framework + ActixWeb, + /// Rocket - Simple and easy-to-use web framework + Rocket, + /// Loco - Batteries included web framework based on Axum Loco, - /// Poem web framework + /// Salvo - Powerful and simple web framework + Salvo, + /// Poem - Full-featured and easy-to-use web framework Poem, - /// Poise Discord framework + /// Poise - Discord Bot framework with good slash command support Poise, - /// Rocket web framework - Rocket, - /// Salvo web framework - Salvo, - /// Serenity Discord framework + /// Serenity - Discord Bot framework Serenity, - /// Thruster web framework + /// Tower - Modular service library + Tower, + /// Thruster - Web framework Thruster, - /// Tide web framework + /// Tide - Web framework Tide, - /// Tower web framework - Tower, - /// Warp web framework + /// Warp - Web framework Warp, - /// No template - Custom empty service + /// No template - Make a custom service None, } -pub const EXAMPLES_REPO: &str = "https://github.com/shuttle-hq/shuttle-examples"; - #[derive(Clone, Debug, PartialEq)] pub struct TemplateLocation { pub auto_path: String, diff --git a/cargo-shuttle/src/init.rs b/cargo-shuttle/src/init.rs index fb3a39ccd..5a77b79f9 100644 --- a/cargo-shuttle/src/init.rs +++ b/cargo-shuttle/src/init.rs @@ -12,7 +12,7 @@ use gix::create::{self, Kind}; use gix::remote::fetch::Shallow; use gix::{open, progress}; use regex::Regex; -use shuttle_common::constants::SHUTTLE_EXAMPLES_README; +use shuttle_common::constants::EXAMPLES_README; use tempfile::{Builder, TempDir}; use toml_edit::{value, Document}; use url::Url; @@ -101,8 +101,8 @@ fn setup_template(auto_path: &str) -> Result { // `owner` and `name` are required for the regex to // match. Thus, we don't need to check if they exist. let url = format!("{vendor}{}/{}.git", &caps["owner"], &caps["name"]); - gix_clone(&url, temp_dir.path()) - .with_context(|| format!("Failed to clone template Git repository at {url}"))?; + println!(r#"Cloning from "{}"..."#, url); + gix_clone(&url, temp_dir.path()).context("Failed to clone git repository")?; } else if Path::new(auto_path).is_absolute() || auto_path.starts_with('.') { if Path::new(auto_path).exists() { copy_dirs(Path::new(auto_path), temp_dir.path(), GitDir::Copy)?; @@ -121,7 +121,7 @@ fn setup_template(auto_path: &str) -> Result { or use another method of specifying the template location." ); println!( - "HINT: You can find examples of how to select a template here: {SHUTTLE_EXAMPLES_README}" + "HINT: You can find examples of how to select a template here: {EXAMPLES_README}" ); anyhow::bail!("invalid URL scheme") } diff --git a/cargo-shuttle/src/lib.rs b/cargo-shuttle/src/lib.rs index dbfec554b..eca66eb3d 100644 --- a/cargo-shuttle/src/lib.rs +++ b/cargo-shuttle/src/lib.rs @@ -22,7 +22,7 @@ use clap_complete::{generate, Shell}; use clap_mangen::Man; use config::RequestContext; use crossterm::style::Stylize; -use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, Password}; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select}; use flate2::write::GzEncoder; use flate2::Compression; use futures::{StreamExt, TryFutureExt}; @@ -34,9 +34,9 @@ use indicatif::ProgressBar; use indoc::{formatdoc, printdoc}; use shuttle_common::{ constants::{ - API_URL_DEFAULT, DEFAULT_IDLE_MINUTES, EXECUTABLE_DIRNAME, RESOURCE_SCHEMA_VERSION, - SHUTTLE_CLI_DOCS_URL, SHUTTLE_GH_ISSUE_URL, SHUTTLE_IDLE_DOCS_URL, - SHUTTLE_INSTALL_DOCS_URL, SHUTTLE_LOGIN_URL, STORAGE_DIRNAME, + API_URL_DEFAULT, DEFAULT_IDLE_MINUTES, EXAMPLES_REPO, EXECUTABLE_DIRNAME, + RESOURCE_SCHEMA_VERSION, SHUTTLE_GH_ISSUE_URL, SHUTTLE_IDLE_DOCS_URL, + SHUTTLE_INSTALL_DOCS_URL, SHUTTLE_LOGIN_URL, STORAGE_DIRNAME, TEMPLATES_SCHEMA_VERSION, }, deployment::{DEPLOYER_END_MESSAGES_BAD, DEPLOYER_END_MESSAGES_GOOD}, models::{ @@ -49,7 +49,9 @@ use shuttle_common::{ resource::get_resource_tables, }, resource::{self, ResourceInput, ShuttleResourceOutput}, - semvers_are_compatible, ApiKey, DatabaseResource, DbInput, LogItem, VersionInfo, + semvers_are_compatible, + templates::TemplatesSchema, + ApiKey, DatabaseResource, DbInput, LogItem, VersionInfo, }; use shuttle_proto::{ provisioner::{provisioner_server::Provisioner, DatabaseRequest}, @@ -59,7 +61,7 @@ use shuttle_service::{ builder::{build_workspace, BuiltService}, runner, Environment, }; -use strum::IntoEnumIterator; +use strum::{EnumMessage, VariantArray}; use tar::Builder; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Child; @@ -71,7 +73,7 @@ use uuid::Uuid; pub use crate::args::{Command, ProjectArgs, RunArgs, ShuttleArgs}; use crate::args::{ DeployArgs, DeploymentCommand, InitArgs, LoginArgs, LogoutArgs, ProjectCommand, - ProjectStartArgs, ResourceCommand, EXAMPLES_REPO, + ProjectStartArgs, ResourceCommand, TemplateLocation, }; use crate::client::Client; use crate::provisioner_server::LocalProvisioner; @@ -195,13 +197,20 @@ impl Shuttle { client.set_api_key(self.ctx.api_key()?); } self.client = Some(client); - self.check_api_versions().await?; + if !args.offline { + self.check_api_versions().await?; + } } let res = match args.cmd { Command::Init(init_args) => { - self.init(init_args, args.project_args, provided_path_to_init) - .await + self.init( + init_args, + args.project_args, + provided_path_to_init, + args.offline, + ) + .await } Command::Generate(GenerateCommand::Manpage) => self.generate_manpage(), Command::Generate(GenerateCommand::Shell { shell, output }) => { @@ -312,6 +321,7 @@ impl Shuttle { args: InitArgs, mut project_args: ProjectArgs, provided_path_to_init: bool, + offline: bool, ) -> Result { // Turns the template or git args (if present) to a repo+folder. let git_template = args.git_template()?; @@ -427,21 +437,132 @@ impl Shuttle { args.path.clone() }; - // 4. Ask for the framework + // 4. Ask for the template let template = match git_template { Some(git_template) => git_template, None => { - println!( - "Shuttle works with a range of web frameworks. Which one do you want to use?" - ); - let frameworks = args::InitTemplateArg::iter().collect::>(); - let index = FuzzySelect::with_theme(&theme) - .with_prompt("Framework") - .items(&frameworks) - .default(0) - .interact()?; - println!(); - frameworks[index].template() + // Try to present choices from our up-to-date examples. + // Fall back to the internal (potentially outdated) list. + let schema = if offline { + None + } else { + get_templates_schema() + .await + .map_err(|_| { + println!( + "{}", + "Failed to look up template list. Falling back to internal list." + .yellow() + ) + }) + .ok() + .and_then(|s| { + if s.version == TEMPLATES_SCHEMA_VERSION { + return Some(s); + } + println!( + "{}", + "Template list with incompatible version found. Consider updating cargo-shuttle. Falling back to internal list." + .yellow() + ); + + None + }) + }; + if let Some(schema) = schema { + println!("What type of project template would you like to start from?"); + let i = Select::with_theme(&theme) + .items(&[ + "A Hello World app in a supported framework", + "Browse our full library of templates", // TODO(when templates page is live): Add link to it? + ]) + .clear(false) + .default(0) + .interact()?; + println!(); + if i == 0 { + // Use a Hello world starter + let mut starters = schema.starters.into_values().collect::>(); + starters.sort_by_key(|t| { + // Make the "No templates" appear last in the list + if t.title.starts_with("No") { + "zzz".to_owned() + } else { + t.title.clone() + } + }); + let starter_strings = starters + .iter() + .map(|t| { + format!("{} - {}", t.title.clone().bold(), t.description.clone()) + }) + .collect::>(); + let index = Select::with_theme(&theme) + .with_prompt("Select template") + .items(&starter_strings) + .default(0) + .interact()?; + println!(); + let path = starters[index] + .path + .clone() + .expect("starter to have a path"); + + TemplateLocation { + auto_path: EXAMPLES_REPO.into(), + subfolder: Some(path), + } + } else { + // Browse all non-starter templates + let mut templates = schema.templates.into_values().collect::>(); + templates.sort_by_key(|t| t.title.clone()); + let template_strings = templates + .iter() + .map(|t| { + format!( + "{} - {}{}", + t.title.clone().bold(), + t.description.clone(), + t.tags + .first() + .map(|tag| format!(" ({tag})").dim().to_string()) + .unwrap_or_default(), + ) + }) + .collect::>(); + let index = Select::with_theme(&theme) + .with_prompt("Select template") + .items(&template_strings) + .default(0) + .interact()?; + println!(); + let path = templates[index] + .path + .clone() + .expect("template to have a path"); + + TemplateLocation { + auto_path: EXAMPLES_REPO.into(), + subfolder: Some(path), + } + } + } else { + println!("Shuttle works with many frameworks. Which one do you want to use?"); + let frameworks = args::InitTemplateArg::VARIANTS; + let framework_strings = frameworks + .iter() + .map(|t| { + t.get_documentation() + .expect("all template variants to have docs") + }) + .collect::>(); + let index = Select::with_theme(&theme) + .items(&framework_strings) + .default(0) + .interact()?; + println!(); + frameworks[index].template() + } } }; @@ -462,17 +583,6 @@ impl Shuttle { )?; println!(); - printdoc!( - " - Hint: Check the examples repo for a full list of templates: - {EXAMPLES_REPO} - Hint: You can also use `cargo shuttle init --from` to clone templates. - See {SHUTTLE_CLI_DOCS_URL} - or run `cargo shuttle init --help` - " - ); - println!(); - // 6. Confirm that the user wants to create the project environment on Shuttle let should_create_environment = if !interactive { args.create_env @@ -2105,6 +2215,24 @@ impl Shuttle { } } +// /// Can be used during testing +// async fn get_templates_schema() -> Result { +// Ok(toml::from_str(include_str!( +// "../../examples/templates.toml" +// ))?) +// } +async fn get_templates_schema() -> Result { + let client = reqwest::Client::new(); + Ok(toml::from_str( + &client + .get(shuttle_common::constants::EXAMPLES_TEMPLATES_TOML) + .send() + .await? + .text() + .await?, + )?) +} + fn is_dirty(repo: &Repository) -> Result<()> { let mut status_options = StatusOptions::new(); status_options.include_untracked(true); diff --git a/cargo-shuttle/tests/integration/init.rs b/cargo-shuttle/tests/integration/init.rs index e9ea1ceba..74a1ec74b 100644 --- a/cargo-shuttle/tests/integration/init.rs +++ b/cargo-shuttle/tests/integration/init.rs @@ -225,7 +225,13 @@ fn interactive_rocket_init() -> Result<(), Box> { let bin_path = assert_cmd::cargo::cargo_bin("cargo-shuttle"); let mut command = Command::new(bin_path); - command.args(["init", "--force-name", "--api-key", "dh9z58jttoes3qvt"]); + command.args([ + "--offline", + "init", + "--force-name", + "--api-key", + "dh9z58jttoes3qvt", + ]); let mut session = rexpect::session::spawn_command(command, Some(EXPECT_TIMEOUT_MS))?; session.exp_string("What do you want to name your project?")?; @@ -234,12 +240,8 @@ fn interactive_rocket_init() -> Result<(), Box> { session.exp_string("Where should we create this project?")?; session.exp_string("Directory")?; session.send_line(temp_dir_path.join("my-project").to_str().unwrap())?; - session.exp_string( - "Shuttle works with a range of web frameworks. Which one do you want to use?", - )?; - session.exp_string("Framework")?; - // Partial input should be enough to match "rocket" - session.send_line("roc")?; + session.exp_string("Which one do you want to use?")?; + session.send_line("\t\t")?; session.exp_string("Creating project")?; session.exp_string("container on Shuttle?")?; session.send("n")?; @@ -260,7 +262,13 @@ fn interactive_rocket_init_manually_choose_template() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Strin working_directory: working_directory.clone(), name: None, }, + offline: false, cmd: Command::Run(run_args), }, false, diff --git a/common/Cargo.toml b/common/Cargo.toml index 440bc244b..ebfc71008 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shuttle-common" -version = "0.40.2" +version = "0.40.3" edition.workspace = true license.workspace = true repository.workspace = true @@ -14,11 +14,11 @@ async-trait = { workspace = true, optional = true } axum = { workspace = true, optional = true } bytes = { workspace = true, optional = true } chrono = { workspace = true } -comfy-table = { version = "6.2.0", optional = true } +comfy-table = { workspace = true, optional = true } crossterm = { workspace = true, optional = true } headers = { workspace = true, optional = true } http = { workspace = true, optional = true } -http-body = { version = "0.4.5", optional = true } +http-body = { workspace = true, optional = true } hyper = { workspace = true, optional = true } jsonwebtoken = { workspace = true, optional = true } once_cell = { workspace = true, optional = true } diff --git a/common/src/constants.rs b/common/src/constants.rs index cd93a08d0..bf502f1c3 100644 --- a/common/src/constants.rs +++ b/common/src/constants.rs @@ -17,10 +17,12 @@ pub const SHUTTLE_STATUS_URL: &str = "https://status.shuttle.rs"; pub const SHUTTLE_LOGIN_URL: &str = "https://console.shuttle.rs/new-project"; pub const SHUTTLE_GH_ISSUE_URL: &str = "https://github.com/shuttle-hq/shuttle/issues/new/choose"; pub const SHUTTLE_INSTALL_DOCS_URL: &str = "https://docs.shuttle.rs/getting-started/installation"; -pub const SHUTTLE_CLI_DOCS_URL: &str = "https://docs.shuttle.rs/getting-started/shuttle-commands"; pub const SHUTTLE_IDLE_DOCS_URL: &str = "https://docs.shuttle.rs/getting-started/idle-projects"; -pub const SHUTTLE_EXAMPLES_README: &str = +pub const EXAMPLES_REPO: &str = "https://github.com/shuttle-hq/shuttle-examples"; +pub const EXAMPLES_README: &str = "https://github.com/shuttle-hq/shuttle-examples#how-to-clone-run-and-deploy-an-example"; +pub const EXAMPLES_TEMPLATES_TOML: &str = + "https://raw.githubusercontent.com/shuttle-hq/shuttle-examples/main/templates.toml"; // Crate name for checking cargo metadata pub const RUNTIME_NAME: &str = "shuttle-runtime"; @@ -28,6 +30,9 @@ pub const RUNTIME_NAME: &str = "shuttle-runtime"; /// Current version field in requests to provisioner pub const RESOURCE_SCHEMA_VERSION: u32 = 1; +/// Current version field in `examples/templates.toml` +pub const TEMPLATES_SCHEMA_VERSION: u32 = 1; + /// Timeframe before a project is considered idle pub const DEFAULT_IDLE_MINUTES: u64 = 30; diff --git a/common/src/templates.rs b/common/src/templates.rs index 830925fd2..7a93032c8 100644 --- a/common/src/templates.rs +++ b/common/src/templates.rs @@ -2,10 +2,21 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -/// Schema used in `examples/templates.toml` and services that parses it +/// Schema used in `examples/templates.toml` and services that parse it #[derive(Debug, Serialize, Deserialize)] pub struct TemplatesSchema { + /// Version of this schema + pub version: u32, + /// Very basic templates, typically Hello World + pub starters: HashMap, + /// Non-starter templates pub templates: HashMap, + /// Examples not meant to be templates + pub examples: HashMap, + /// Examples with attached tutorials + pub tutorials: HashMap, + /// Templates made by community members + pub community_templates: HashMap, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -13,12 +24,9 @@ pub struct TemplateDefinition { /// Title of the template pub title: String, /// A short description of the template - pub description: Option, + pub description: String, /// Path relative to the repo root pub path: Option, - /// "starter" OR "template" (default) OR "tutorial" - #[serde(default)] - pub r#type: TemplateType, /// List of areas where this template is useful. Examples: "Web app", "Discord bot", "Monitoring", "Automation", "Utility" pub use_cases: Vec, /// List of keywords that describe the template. Examples: "axum", "serenity", "typescript", "saas", "fullstack", "database" @@ -29,19 +37,9 @@ pub struct TemplateDefinition { /// If this template is available in the `cargo shuttle init --template` short-hand options, add that name here pub template: Option, - /// Set this to true if this is a community template outside of the shuttle-examples repo - pub community: Option, + ////// Fields for community templates /// GitHub username of the author of the community template pub author: Option, /// URL to the repo of the community template pub repo: Option, } - -#[derive(Debug, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TemplateType { - Starter, - #[default] - Template, - Tutorial, -}