From e2490777b720c1a5594cec42e7edb5f7fd5fe105 Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Thu, 26 Dec 2024 09:17:30 +0200 Subject: [PATCH] allow override loco template --- .github/workflows/loco-new.yml | 2 + loco-gen/Cargo.toml | 6 +- loco-gen/src/controller.rs | 53 ++- loco-gen/src/lib.rs | 307 +++++++++++------ loco-gen/src/migration.rs | 39 +-- loco-gen/src/model.rs | 90 +---- loco-gen/src/scaffold.rs | 65 +--- loco-gen/src/template.rs | 202 +++++++++++ .../src/templates/deployment/docker/docker.t | 2 +- loco-gen/src/templates/mailer/mailer.t | 4 + .../src/templates/migration/add_columns.t | 14 +- .../src/templates/migration/remove_columns.t | 16 +- loco-gen/src/templates/scaffold/html/view.t | 8 +- loco-gen/src/templates/scaffold/htmx/view.t | 8 +- loco-gen/src/templates/worker/test.t | 11 +- loco-gen/src/templates/worker/worker.t | 11 +- loco-new/Cargo.toml | 2 +- loco-new/base_template/config/test.yaml.t | 2 +- loco-new/tests/wizard/new.rs | 321 +++++++++++++----- src/cli.rs | 143 ++++++-- src/logger.rs | 1 + 21 files changed, 862 insertions(+), 445 deletions(-) create mode 100644 loco-gen/src/template.rs diff --git a/.github/workflows/loco-new.yml b/.github/workflows/loco-new.yml index 4db13adee..1e23c6d73 100644 --- a/.github/workflows/loco-new.yml +++ b/.github/workflows/loco-new.yml @@ -6,9 +6,11 @@ on: - master paths: - "loco-new/**" + - "loco-gen/**" pull_request: paths: - "loco-new/**" + - "loco-gen/**" env: RUST_TOOLCHAIN: stable diff --git a/loco-gen/Cargo.toml b/loco-gen/Cargo.toml index 56b7583e7..0fc31f2fb 100644 --- a/loco-gen/Cargo.toml +++ b/loco-gen/Cargo.toml @@ -15,7 +15,7 @@ path = "src/lib.rs" [dependencies] cruet = "0.14.0" -rrgen = "0.5.3" +rrgen = "0.5.5" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -24,9 +24,11 @@ tracing = { workspace = true } chrono = { workspace = true } clap = { version = "4.4.7", features = ["derive"] } -dialoguer = "0.11" duct = "0.13" +include_dir = { version = "0.7.4" } [dev-dependencies] tree-fs = { version = "0.2.1" } syn = { version = "2", features = ["full"] } +insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] } +rstest = "0.23.0" diff --git a/loco-gen/src/controller.rs b/loco-gen/src/controller.rs index 892b443e2..8bb8a71cd 100644 --- a/loco-gen/src/controller.rs +++ b/loco-gen/src/controller.rs @@ -1,18 +1,8 @@ -use rrgen::RRgen; -use serde_json::json; - +use super::{AppInfo, Result}; use crate as gen; - -const API_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/api/controller.t"); -const API_CONTROLLER_TEST_T: &str = include_str!("templates/controller/api/test.t"); - -const HTMX_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/htmx/controller.t"); -const HTMX_VIEW_T: &str = include_str!("templates/controller/htmx/view.t"); - -const HTML_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/html/controller.t"); -const HTML_VIEW_T: &str = include_str!("templates/controller/html/view.t"); - -use super::{collect_messages, AppInfo, Result}; +use rrgen::{GenResult, RRgen}; +use serde_json::json; +use std::path::Path; pub fn generate( rrgen: &RRgen, @@ -20,34 +10,35 @@ pub fn generate( actions: &[String], kind: &gen::ScaffoldKind, appinfo: &AppInfo, -) -> Result { +) -> Result> { let vars = json!({"name": name, "actions": actions, "pkg_name": appinfo.app_name}); match kind { - gen::ScaffoldKind::Api => { - let res1 = rrgen.generate(API_CONTROLLER_CONTROLLER_T, &vars)?; - let res2 = rrgen.generate(API_CONTROLLER_TEST_T, &vars)?; - let messages = collect_messages(vec![res1, res2]); - Ok(messages) - } + gen::ScaffoldKind::Api => gen::render_template(rrgen, Path::new("controller/api"), &vars), gen::ScaffoldKind::Html => { - let mut messages = Vec::new(); - let res = rrgen.generate(HTML_CONTROLLER_CONTROLLER_T, &vars)?; - messages.push(res); + let mut gen_result = + gen::render_template(rrgen, Path::new("controller/html/controller.t"), &vars)?; for action in actions { let vars = json!({"name": name, "action": action, "pkg_name": appinfo.app_name}); - messages.push(rrgen.generate(HTML_VIEW_T, &vars)?); + gen_result.extend(gen::render_template( + rrgen, + Path::new("controller/html/view.t"), + &vars, + )?); } - Ok(collect_messages(messages)) + Ok(gen_result) } gen::ScaffoldKind::Htmx => { - let mut messages = Vec::new(); - let res = rrgen.generate(HTMX_CONTROLLER_CONTROLLER_T, &vars)?; - messages.push(res); + let mut gen_result = + gen::render_template(rrgen, Path::new("controller/htmx/controller.t"), &vars)?; for action in actions { let vars = json!({"name": name, "action": action, "pkg_name": appinfo.app_name}); - messages.push(rrgen.generate(HTMX_VIEW_T, &vars)?); + gen_result.extend(gen::render_template( + rrgen, + Path::new("controller/htmx/view.t"), + &vars, + )?); } - Ok(collect_messages(messages)) + Ok(gen_result) } } } diff --git a/loco-gen/src/lib.rs b/loco-gen/src/lib.rs index 0a1f20cf3..74201b525 100644 --- a/loco-gen/src/lib.rs +++ b/loco-gen/src/lib.rs @@ -2,11 +2,17 @@ // TODO: should be more properly aligned with extracting out the db-related gen // code and then feature toggling it #![allow(dead_code)] -use rrgen::{GenResult, RRgen}; +pub use rrgen::{GenResult, RRgen}; use serde::{Deserialize, Serialize}; -use serde_json::json; - +use serde_json::{json, Value}; mod controller; +use std::{ + fs, + path::{Path, PathBuf}, + str::FromStr, + sync::OnceLock, +}; + #[cfg(feature = "with-db")] mod infer; #[cfg(feature = "with-db")] @@ -15,29 +21,9 @@ mod migration; mod model; #[cfg(feature = "with-db")] mod scaffold; +pub mod template; #[cfg(test)] mod testutil; -use std::{str::FromStr, sync::OnceLock}; - -const MAILER_T: &str = include_str!("templates/mailer/mailer.t"); -const MAILER_SUB_T: &str = include_str!("templates/mailer/subject.t"); -const MAILER_TEXT_T: &str = include_str!("templates/mailer/text.t"); -const MAILER_HTML_T: &str = include_str!("templates/mailer/html.t"); - -const TASK_T: &str = include_str!("templates/task/task.t"); -const TASK_TEST_T: &str = include_str!("templates/task/test.t"); - -const SCHEDULER_T: &str = include_str!("templates/scheduler/scheduler.t"); - -const WORKER_T: &str = include_str!("templates/worker/worker.t"); -const WORKER_TEST_T: &str = include_str!("templates/worker/test.t"); - -// Deployment templates -const DEPLOYMENT_DOCKER_T: &str = include_str!("templates/deployment/docker/docker.t"); -const DEPLOYMENT_DOCKER_IGNORE_T: &str = include_str!("templates/deployment/docker/ignore.t"); -const DEPLOYMENT_SHUTTLE_T: &str = include_str!("templates/deployment/shuttle/shuttle.t"); -const DEPLOYMENT_SHUTTLE_CONFIG_T: &str = include_str!("templates/deployment/shuttle/config.t"); -const DEPLOYMENT_NGINX_T: &str = include_str!("templates/deployment/nginx/nginx.t"); const DEPLOYMENT_SHUTTLE_RUNTIME_VERSION: &str = "0.46.0"; @@ -51,6 +37,8 @@ const DEPLOYMENT_OPTIONS: &[(&str, DeploymentKind)] = &[ pub enum Error { #[error("{0}")] Message(String), + #[error("template {} not found", path.display())] + TemplateNotFound { path: PathBuf }, #[error(transparent)] RRgen(#[from] rrgen::Error), #[error(transparent)] @@ -123,7 +111,7 @@ pub enum ScaffoldKind { Htmx, } -#[derive(Debug, Clone)] +#[derive(clap::ValueEnum, Debug, Clone)] pub enum DeploymentKind { Docker, Shuttle, @@ -136,6 +124,7 @@ impl FromStr for DeploymentKind { match s.to_lowercase().as_str() { "docker" => Ok(Self::Docker), "shuttle" => Ok(Self::Shuttle), + "nginx" => Ok(Self::Nginx), _ => Err(()), } } @@ -197,6 +186,7 @@ pub enum Component { name: String, }, Deployment { + kind: DeploymentKind, fallback_file: Option, asset_folder: Option, host: String, @@ -212,9 +202,11 @@ pub struct AppInfo { /// # Errors /// /// This function will return an error if it fails -#[allow(clippy::too_many_lines)] -pub fn generate(component: Component, appinfo: &AppInfo) -> Result<()> { - let rrgen = RRgen::default(); +pub fn generate( + rrgen: &RRgen, + component: Component, + appinfo: &AppInfo, +) -> Result> { /* (1) XXX: remove hooks generic from child generator, materialize it here and pass it @@ -222,104 +214,106 @@ pub fn generate(component: Component, appinfo: &AppInfo) -> Result<()> { this will allow us to test without an app instance (2) proceed to test individual generators */ - match component { + let get_result = match component { #[cfg(feature = "with-db")] Component::Model { name, link, fields } => { - println!( - "{}", - model::generate(&rrgen, &name, link, &fields, appinfo)? - ); + model::generate(rrgen, &name, link, &fields, appinfo)? } #[cfg(feature = "with-db")] Component::Scaffold { name, fields, kind } => { - println!( - "{}", - scaffold::generate(&rrgen, &name, &fields, &kind, appinfo)? - ); + scaffold::generate(rrgen, &name, &fields, &kind, appinfo)? } #[cfg(feature = "with-db")] Component::Migration { name, fields } => { - migration::generate(&rrgen, &name, &fields, appinfo)?; + migration::generate(rrgen, &name, &fields, appinfo)? } Component::Controller { name, actions, kind, - } => { - println!( - "{}", - controller::generate(&rrgen, &name, &actions, &kind, appinfo)? - ); - } + } => controller::generate(rrgen, &name, &actions, &kind, appinfo)?, Component::Task { name } => { let vars = json!({"name": name, "pkg_name": appinfo.app_name}); - rrgen.generate(TASK_T, &vars)?; - rrgen.generate(TASK_TEST_T, &vars)?; + render_template(rrgen, Path::new("task"), &vars)? } Component::Scheduler {} => { let vars = json!({"pkg_name": appinfo.app_name}); - rrgen.generate(SCHEDULER_T, &vars)?; + render_template(rrgen, Path::new("scheduler"), &vars)? } Component::Worker { name } => { let vars = json!({"name": name, "pkg_name": appinfo.app_name}); - rrgen.generate(WORKER_T, &vars)?; - rrgen.generate(WORKER_TEST_T, &vars)?; + render_template(rrgen, Path::new("worker"), &vars)? } Component::Mailer { name } => { let vars = json!({ "name": name }); - rrgen.generate(MAILER_T, &vars)?; - rrgen.generate(MAILER_SUB_T, &vars)?; - rrgen.generate(MAILER_TEXT_T, &vars)?; - rrgen.generate(MAILER_HTML_T, &vars)?; + render_template(rrgen, Path::new("mailer"), &vars)? } Component::Deployment { + kind, fallback_file, asset_folder, host, port, - } => { - let deployment_kind = match std::env::var("LOCO_DEPLOYMENT_KIND") { - Ok(kind) => kind - .parse::() - .map_err(|_e| Error::Message(format!("deployment {kind} not supported")))?, - Err(_err) => prompt_deployment_selection().map_err(Box::from)?, - }; - - match deployment_kind { - DeploymentKind::Docker => { - let vars = json!({ - "pkg_name": appinfo.app_name, - "copy_asset_folder": asset_folder.unwrap_or_default(), - "fallback_file": fallback_file.unwrap_or_default() - }); - rrgen.generate(DEPLOYMENT_DOCKER_T, &vars)?; - rrgen.generate(DEPLOYMENT_DOCKER_IGNORE_T, &vars)?; - } - DeploymentKind::Shuttle => { - let vars = json!({ - "pkg_name": appinfo.app_name, - "shuttle_runtime_version": DEPLOYMENT_SHUTTLE_RUNTIME_VERSION, - "with_db": cfg!(feature = "with-db") - }); - rrgen.generate(DEPLOYMENT_SHUTTLE_T, &vars)?; - rrgen.generate(DEPLOYMENT_SHUTTLE_CONFIG_T, &vars)?; - } - DeploymentKind::Nginx => { - let host = host.replace("http://", "").replace("https://", ""); - let vars = json!({ - "pkg_name": appinfo.app_name, - "domain": host, - "port": port - }); - rrgen.generate(DEPLOYMENT_NGINX_T, &vars)?; - } + } => match kind { + DeploymentKind::Docker => { + let vars = json!({ + "pkg_name": appinfo.app_name, + "copy_asset_folder": asset_folder.unwrap_or_default(), + "fallback_file": fallback_file.unwrap_or_default() + }); + render_template(rrgen, Path::new("deployment/docker"), &vars)? } - } + DeploymentKind::Shuttle => { + let vars = json!({ + "pkg_name": appinfo.app_name, + "shuttle_runtime_version": DEPLOYMENT_SHUTTLE_RUNTIME_VERSION, + "with_db": cfg!(feature = "with-db") + }); + + render_template(rrgen, Path::new("deployment/shuttle"), &vars)? + } + DeploymentKind::Nginx => { + let host = host.replace("http://", "").replace("https://", ""); + let vars = json!({ + "pkg_name": appinfo.app_name, + "domain": host, + "port": port + }); + render_template(rrgen, Path::new("deployment/nginx"), &vars)? + } + }, + }; + + Ok(get_result) +} + +fn render_template(rrgen: &RRgen, template: &Path, vars: &Value) -> Result> { + let template_files = template::collect_files_from_path(template)?; + + let mut gen_result = vec![]; + for template in template_files { + let custom_template = Path::new(template::DEFAULT_LOCAL_TEMPLATE).join(template.path()); + + if custom_template.exists() { + let content = fs::read_to_string(&custom_template).map_err(|err| { + tracing::error!(custom_template = %custom_template.display(), "could not read custom template"); + err + })?; + gen_result.push(rrgen.generate(&content, vars)?); + } else { + let content = template.contents_utf8().ok_or(Error::Message(format!( + "could not get template content: {}", + template.path().display() + )))?; + gen_result.push(rrgen.generate(content, vars)?); + }; } - Ok(()) + + Ok(gen_result) } -fn collect_messages(results: Vec) -> String { +#[must_use] +pub fn collect_messages(results: &Vec) -> String { let mut messages = String::new(); for res in results { if let rrgen::GenResult::Generated { @@ -331,17 +325,126 @@ fn collect_messages(results: Vec) -> String { } messages } -use dialoguer::{theme::ColorfulTheme, Select}; -fn prompt_deployment_selection() -> Result { - let options: Vec = DEPLOYMENT_OPTIONS.iter().map(|t| t.0.to_string()).collect(); +/// Copies template files to a specified destination directory. +/// +/// This function copies files from the specified template path to the destination directory. +/// If the specified path is `/` or `.`, it copies all files from the templates directory. +/// If the path does not exist in the templates, it returns an error. +/// +/// # Errors +/// when could not copy the given template path +pub fn copy_template(path: &Path, to: &Path) -> Result<()> { + let copy_template_path = if path == Path::new("/") || path == Path::new(".") { + None + } else if !template::exists(path) { + return Err(Error::TemplateNotFound { + path: path.to_path_buf(), + }); + } else { + Some(path) + }; + + let copy_files = if let Some(path) = copy_template_path { + template::collect_files_from_path(path)? + } else { + template::collect_files() + }; + + for f in copy_files { + let copy_to = to.join(f.path()); + if copy_to.exists() { + tracing::debug!( + template_file = %copy_to.display(), + "skipping copy template file. already exists" + ); + continue; + } + match copy_to.parent() { + Some(parent) => { + fs::create_dir_all(parent)?; + } + None => { + return Err(Error::Message(format!( + "could not get parent folder of {}", + copy_to.display() + ))) + } + } + + fs::write(©_to, f.contents())?; + tracing::trace!( + template = %copy_to.display(), + "copy template successfully" + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_template_not_found() { + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .create() + .expect("create temp file"); + let path = Path::new("nonexistent-template"); + + let result = copy_template(path, tree_fs.root.as_path()); + assert!(result.is_err()); + if let Err(Error::TemplateNotFound { path: p }) = result { + assert_eq!(p, path.to_path_buf()); + } else { + panic!("Expected TemplateNotFound error"); + } + } - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("❯ Choose your deployment") - .items(&options) - .default(0) - .interact() - .map_err(Error::msg)?; + #[test] + fn test_copy_template_valid_folder_template() { + let temp_fs = tree_fs::TreeBuilder::default() + .drop(true) + .create() + .expect("Failed to create temporary file system"); - Ok(DEPLOYMENT_OPTIONS[selection].1.clone()) + let template_dir = template::tests::find_first_dir(); + + let copy_result = copy_template(template_dir.path(), temp_fs.root.as_path()); + assert!( + copy_result.is_ok(), + "Failed to copy template from directory {:?}", + template_dir.path() + ); + + let template_files = template::collect_files_from_path(template_dir.path()) + .expect("Failed to collect files from the template directory"); + + assert!( + !template_files.is_empty(), + "No files found in the template directory" + ); + + for template_file in template_files { + let copy_file_path = temp_fs.root.join(template_file.path()); + + assert!( + copy_file_path.exists(), + "Copy file does not exist: {copy_file_path:?}" + ); + + let copy_content = + fs::read_to_string(©_file_path).expect("Failed to read coped file content"); + + assert_eq!( + template_file + .contents_utf8() + .expect("Failed to get template file content"), + copy_content, + "Content mismatch in file: {copy_file_path:?}" + ); + } + } } diff --git a/loco-gen/src/migration.rs b/loco-gen/src/migration.rs index f53918c71..0b0a92141 100644 --- a/loco-gen/src/migration.rs +++ b/loco-gen/src/migration.rs @@ -1,20 +1,8 @@ +use crate::{infer, model::get_columns_and_references, render_template, AppInfo, Result}; use chrono::Utc; -use rrgen::RRgen; +use rrgen::{GenResult, RRgen}; use serde_json::json; - -use super::Result; -use crate::{ - infer, - model::{get_columns_and_references, MODEL_T}, -}; - -const MIGRATION_T: &str = include_str!("templates/migration/empty.t"); -const ADD_COLS_T: &str = include_str!("templates/migration/add_columns.t"); -const ADD_REFS_T: &str = include_str!("templates/migration/add_references.t"); -const REMOVE_COLS_T: &str = include_str!("templates/migration/remove_columns.t"); -const JOIN_TABLE_T: &str = include_str!("templates/migration/join_table.t"); - -use super::{collect_messages, AppInfo}; +use std::path::Path; /// skipping some fields from the generated models. /// For example, the `created_at` and `updated_at` fields are automatically @@ -26,32 +14,32 @@ pub fn generate( name: &str, fields: &[(String, String)], appinfo: &AppInfo, -) -> Result { +) -> Result> { let pkg_name: &str = &appinfo.app_name; let ts = Utc::now(); let res = infer::guess_migration_type(name); - let migration_gen = match res { + match res { // NOTE: re-uses the 'new model' migration template! infer::MigrationType::CreateTable { table } => { let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": table, "ts": ts, "pkg_name": pkg_name, "is_link": false, "columns": columns, "references": references}); - rrgen.generate(MODEL_T, &vars)? + render_template(rrgen, Path::new("model/model.t"), &vars) } infer::MigrationType::AddColumns { table } => { let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "is_link": false, "columns": columns, "references": references}); - rrgen.generate(ADD_COLS_T, &vars)? + render_template(rrgen, Path::new("migration/add_columns.t"), &vars) } infer::MigrationType::RemoveColumns { table } => { let (columns, _references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns}); - rrgen.generate(REMOVE_COLS_T, &vars)? + render_template(rrgen, Path::new("migration/remove_columns.t"), &vars) } infer::MigrationType::AddReference { table } => { let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns, "references": references}); - rrgen.generate(ADD_REFS_T, &vars)? + render_template(rrgen, Path::new("migration/add_references.t"), &vars) } infer::MigrationType::CreateJoinTable { table_a, table_b } => { let mut tables = [table_a.clone(), table_b.clone()]; @@ -62,14 +50,11 @@ pub fn generate( (table_b, "references".to_string()), ])?; let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns, "references": references}); - rrgen.generate(JOIN_TABLE_T, &vars)? + render_template(rrgen, Path::new("migration/join_table.t"), &vars) } infer::MigrationType::Empty => { let vars = json!({"name": name, "ts": ts, "pkg_name": pkg_name}); - rrgen.generate(MIGRATION_T, &vars)? + render_template(rrgen, Path::new("migration/empty.t"), &vars) } - }; - - let messages = collect_messages(vec![migration_gen]); - Ok(messages) + } } diff --git a/loco-gen/src/model.rs b/loco-gen/src/model.rs index 238feef03..2b6d17577 100644 --- a/loco-gen/src/model.rs +++ b/loco-gen/src/model.rs @@ -1,17 +1,10 @@ -use std::{collections::HashMap, env::current_dir}; - +use crate::{get_mappings, render_template, AppInfo, Error, Result}; use chrono::Utc; use duct::cmd; -use rrgen::RRgen; +use rrgen::{GenResult, RRgen}; use serde_json::json; - -use super::{Error, Result}; -use crate::get_mappings; - -pub const MODEL_T: &str = include_str!("templates/model/model.t"); -const MODEL_TEST_T: &str = include_str!("templates/model/test.t"); - -use super::{collect_messages, AppInfo}; +use std::path::Path; +use std::{collections::HashMap, env::current_dir}; /// skipping some fields from the generated models. /// For example, the `created_at` and `updated_at` fields are automatically @@ -59,21 +52,21 @@ pub fn get_columns_and_references( } Ok((columns, references)) } + pub fn generate( rrgen: &RRgen, name: &str, is_link: bool, fields: &[(String, String)], appinfo: &AppInfo, -) -> Result { +) -> Result> { let pkg_name: &str = &appinfo.app_name; let ts = Utc::now(); let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "ts": ts, "pkg_name": pkg_name, "is_link": is_link, "columns": columns, "references": references}); - let res1 = rrgen.generate(MODEL_T, &vars)?; - let res2 = rrgen.generate(MODEL_TEST_T, &vars)?; + let gen_result = render_template(rrgen, Path::new("model"), &vars)?; // generate the model files by migrating and re-running seaorm let cwd = current_dir()?; @@ -100,72 +93,5 @@ pub fn generate( )) })?; - let messages = collect_messages(vec![res1, res2]); - Ok(messages) -} - -#[cfg(test)] -mod tests { - use std::{env, process::Command}; - - use crate::{ - testutil::{self, assert_cargo_check, assert_file, assert_single_file_match}, - AppInfo, - }; - - fn with_new_app(app_name: &str, f: F) - where - F: FnOnce(), - { - testutil::with_temp_dir(|_previous, current| { - let status = Command::new("loco") - .args([ - "new", - "-n", - app_name, - "--db", - "sqlite", - "--bg", - "async", - "--assets", - "serverside", - "-a", - ]) - .status() - .expect("cannot run command"); - - assert!(status.success(), "Command failed: loco new -n {app_name}"); - env::set_current_dir(current.join(app_name)) - .expect("Failed to change directory to app"); - f(); // Execute the provided closure - }) - .expect("temp dir setup"); - } - - #[test] - fn test_can_generate_model() { - let rrgen = rrgen::RRgen::default(); - with_new_app("saas", || { - super::generate( - &rrgen, - "movies", - false, - &[("title".to_string(), "string".to_string())], - &AppInfo { - app_name: "saas".to_string(), - }, - ) - .expect("generate"); - assert_file("migration/src/lib.rs", |content| { - content.assert_syntax(); - content.assert_regex_match("_movies::Migration"); - }); - let migration = assert_single_file_match("migration/src", ".*_movies.rs$"); - assert_file(migration.to_str().unwrap(), |content| { - content.assert_syntax(); - content.assert_regex_match("Title"); - }); - assert_cargo_check(); - }); - } + Ok(gen_result) } diff --git a/loco-gen/src/scaffold.rs b/loco-gen/src/scaffold.rs index b56f230d3..f1f5280da 100644 --- a/loco-gen/src/scaffold.rs +++ b/loco-gen/src/scaffold.rs @@ -1,45 +1,24 @@ -use rrgen::RRgen; +use crate::{get_mappings, model, render_template, AppInfo, Error, Result, ScaffoldKind}; +use rrgen::{GenResult, RRgen}; use serde_json::json; - -use crate::{self as gen, get_mappings}; - -const API_CONTROLLER_SCAFFOLD_T: &str = include_str!("templates/scaffold/api/controller.t"); -const API_CONTROLLER_TEST_T: &str = include_str!("templates/scaffold/api/test.t"); - -const HTMX_CONTROLLER_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/controller.t"); -const HTMX_BASE_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/base.t"); -const HTMX_VIEW_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view.t"); -const HTMX_VIEW_EDIT_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view_edit.t"); -const HTMX_VIEW_CREATE_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view_create.t"); -const HTMX_VIEW_SHOW_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view_show.t"); -const HTMX_VIEW_LIST_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view_list.t"); - -const HTML_CONTROLLER_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/controller.t"); -const HTML_BASE_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/base.t"); -const HTML_VIEW_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view.t"); -const HTML_VIEW_EDIT_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_edit.t"); -const HTML_VIEW_CREATE_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_create.t"); -const HTML_VIEW_SHOW_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_show.t"); -const HTML_VIEW_LIST_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_list.t"); - -use super::{collect_messages, model, AppInfo, Error, Result}; +use std::path::Path; pub fn generate( rrgen: &RRgen, name: &str, fields: &[(String, String)], - kind: &gen::ScaffoldKind, + kind: &ScaffoldKind, appinfo: &AppInfo, -) -> Result { +) -> Result> { // - scaffold is never a link table // - never run with migration_only, because the controllers will refer to the // models. the models only arrive after migration and entities sync. - let model_messages = model::generate(rrgen, name, false, fields, appinfo)?; + let mut gen_result = model::generate(rrgen, name, false, fields, appinfo)?; let mappings = get_mappings(); let mut columns = Vec::new(); for (fname, ftype) in fields { - if gen::model::IGNORE_FIELDS.contains(&fname.as_str()) { + if model::IGNORE_FIELDS.contains(&fname.as_str()) { tracing::warn!( field = fname, "note that a redundant field was specified, it is already generated automatically" @@ -59,31 +38,15 @@ pub fn generate( } let vars = json!({"name": name, "columns": columns, "pkg_name": appinfo.app_name}); match kind { - gen::ScaffoldKind::Api => { - let res1 = rrgen.generate(API_CONTROLLER_SCAFFOLD_T, &vars)?; - let res2 = rrgen.generate(API_CONTROLLER_TEST_T, &vars)?; - let messages = collect_messages(vec![res1, res2]); - Ok(format!("{model_messages}{messages}")) + ScaffoldKind::Api => { + gen_result.extend(render_template(rrgen, Path::new("scaffold/api"), &vars)?); } - gen::ScaffoldKind::Html => { - rrgen.generate(HTML_CONTROLLER_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_BASE_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_EDIT_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_CREATE_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_SHOW_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_LIST_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_SCAFFOLD_T, &vars)?; - Ok(model_messages) + ScaffoldKind::Html => { + gen_result.extend(render_template(rrgen, Path::new("scaffold/html"), &vars)?); } - gen::ScaffoldKind::Htmx => { - rrgen.generate(HTMX_CONTROLLER_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_BASE_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_EDIT_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_CREATE_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_SHOW_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_LIST_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_SCAFFOLD_T, &vars)?; - Ok(model_messages) + ScaffoldKind::Htmx => { + gen_result.extend(render_template(rrgen, Path::new("scaffold/htmx"), &vars)?); } } + Ok(gen_result) } diff --git a/loco-gen/src/template.rs b/loco-gen/src/template.rs new file mode 100644 index 000000000..926bd0613 --- /dev/null +++ b/loco-gen/src/template.rs @@ -0,0 +1,202 @@ +use crate::{Error, Result}; +use include_dir::{include_dir, Dir, DirEntry, File}; +use std::path::{Path, PathBuf}; + +static TEMPLATES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates"); +pub const DEFAULT_LOCAL_TEMPLATE: &str = ".loco-templates"; + +/// Returns a list of paths that should be ignored during file collection. +#[must_use] +pub fn get_ignored_paths() -> Vec<&'static Path> { + vec![ + #[cfg(not(feature = "with-db"))] + Path::new("scaffold"), + #[cfg(not(feature = "with-db"))] + Path::new("migration"), + #[cfg(not(feature = "with-db"))] + Path::new("model"), + ] +} + +/// Checks whether a specific path exists in the included templates. +#[must_use] +pub fn exists(path: &Path) -> bool { + TEMPLATES.get_entry(path).is_some() +} + +/// Determines whether a given path should be ignored based on the ignored paths list. +#[must_use] +fn is_path_ignored(path: &Path, ignored_paths: &[&Path]) -> bool { + ignored_paths + .iter() + .any(|&ignored| path.starts_with(ignored)) +} + +/// Collects all file paths from the included templates directory recursively. +#[must_use] +pub fn collect() -> Vec { + collect_files_path_recursively(&TEMPLATES) +} + +/// Collects all files from the included templates directory recursively. +#[must_use] +pub fn collect_files() -> Vec<&'static File<'static>> { + collect_files_recursively(&TEMPLATES) +} + +/// Collects all file paths within a specific directory in the templates. +/// +/// # Errors +/// Returns [`Error::TemplateNotFound`] if the directory is not found. +pub fn collect_files_path(path: &Path) -> Result> { + TEMPLATES.get_entry(path).map_or_else( + || { + Err(Error::TemplateNotFound { + path: path.to_path_buf(), + }) + }, + |entry| match entry { + DirEntry::Dir(dir) => Ok(collect_files_path_recursively(dir)), + DirEntry::File(file) => Ok(vec![file.path().to_path_buf()]), + }, + ) +} + +/// Collects all files within a specific directory in the templates. +/// +/// # Errors +/// Returns [`Error::TemplateNotFound`] if the directory is not found. +pub fn collect_files_from_path(path: &Path) -> Result>> { + TEMPLATES.get_entry(path).map_or_else( + || { + Err(Error::TemplateNotFound { + path: path.to_path_buf(), + }) + }, + |entry| match entry { + DirEntry::Dir(dir) => Ok(collect_files_recursively(dir)), + DirEntry::File(file) => Ok(vec![file]), + }, + ) +} + +/// Recursively collects all file paths from a directory, skipping ignored paths. +fn collect_files_path_recursively(dir: &Dir<'_>) -> Vec { + let mut file_paths = Vec::new(); + + for entry in dir.entries() { + match entry { + DirEntry::File(file) => file_paths.push(file.path().to_path_buf()), + DirEntry::Dir(subdir) => { + if !is_path_ignored(subdir.path(), &get_ignored_paths()) { + file_paths.extend(collect_files_path_recursively(subdir)); + } + } + } + } + file_paths +} + +/// Recursively collects all files from a directory, skipping ignored paths. +fn collect_files_recursively<'a>(dir: &'a Dir<'a>) -> Vec<&'a File<'a>> { + let mut files = Vec::new(); + + for entry in dir.entries() { + match entry { + DirEntry::File(file) => files.push(file), + DirEntry::Dir(subdir) => { + if !is_path_ignored(subdir.path(), &get_ignored_paths()) { + files.extend(collect_files_recursively(subdir)); + } + } + } + } + files +} + +#[cfg(test)] +pub mod tests { + use super::*; + use std::path::Path; + + pub fn find_first_dir() -> &'static Dir<'static> { + TEMPLATES.dirs().next().expect("first folder") + } + pub fn find_first_file<'a>(dir: &'a Dir<'a>) -> Option<&'a File<'a>> { + for entry in dir.entries() { + match entry { + DirEntry::File(file) => return Some(file), + DirEntry::Dir(sub_dir) => { + if let Some(file) = find_first_file(sub_dir) { + return Some(file); + } + } + } + } + None + } + + #[test] + fn test_get_ignored_paths() { + let ignored_paths = get_ignored_paths(); + #[cfg(not(feature = "with-db"))] + { + assert!(ignored_paths.contains(&Path::new("scaffold"))); + assert!(ignored_paths.contains(&Path::new("migration"))); + assert!(ignored_paths.contains(&Path::new("model"))); + } + #[cfg(feature = "with-db")] + { + assert!(ignored_paths.is_empty()); + } + } + + #[test] + fn test_exists() { + // test existing folder + let test_folder = TEMPLATES.dirs().next().expect("first folder"); + assert!(exists(test_folder.path())); + assert!(!exists(Path::new("none-folder"))); + + // test existing file + let test_file = find_first_file(&TEMPLATES).expect("find file"); + println!("==== {:#?}", test_file.path()); + assert!(exists(test_file.path())); + assert!(!exists(Path::new("none.rs.t"))); + } + + #[test] + fn test_collect() { + let file_paths = collect(); + assert!(!file_paths.is_empty()); + for path in file_paths { + assert!(TEMPLATES.get_entry(&path).is_some()); + } + } + + #[test] + fn test_collect_files() { + let files = collect_files(); + assert!(!files.is_empty()); + for file in files { + assert!(TEMPLATES.get_entry(file.path()).is_some()); + } + } + + #[test] + fn test_is_path_ignored() { + let path = Path::new("/home/user/project/src/main.rs"); + let ignores = vec![ + Path::new("/home/user/project/target"), + Path::new("/home/user/project/src"), + ]; + + assert!(is_path_ignored(path, &ignores)); + + let non_ignored_path = Path::new("/home/user/project/docs/readme.md"); + assert!(!is_path_ignored(non_ignored_path, &ignores)); + + let empty_ignores: &[&Path] = &[]; + assert!(!is_path_ignored(path, empty_ignores)); + } +} diff --git a/loco-gen/src/templates/deployment/docker/docker.t b/loco-gen/src/templates/deployment/docker/docker.t index 8dcbfa877..9d8563595 100644 --- a/loco-gen/src/templates/deployment/docker/docker.t +++ b/loco-gen/src/templates/deployment/docker/docker.t @@ -2,7 +2,7 @@ to: "dockerfile" skip_exists: true message: "Dockerfile generated successfully." --- -FROM rust:1.74-slim as builder +FROM rust:1.83.0-slim as builder WORKDIR /usr/src/ diff --git a/loco-gen/src/templates/mailer/mailer.t b/loco-gen/src/templates/mailer/mailer.t index cd3f1baf8..dbf8f23dd 100644 --- a/loco-gen/src/templates/mailer/mailer.t +++ b/loco-gen/src/templates/mailer/mailer.t @@ -19,6 +19,10 @@ static welcome: Dir<'_> = include_dir!("src/mailers/{{module_name}}/welcome"); pub struct {{struct_name}} {} impl Mailer for {{struct_name}} {} impl {{struct_name}} { + /// Send an email + /// + /// # Errors + /// When email sending is failed pub async fn send_welcome(ctx: &AppContext, to: &str, msg: &str) -> Result<()> { Self::mail_template( ctx, diff --git a/loco-gen/src/templates/migration/add_columns.t b/loco-gen/src/templates/migration/add_columns.t index 4e97fdf81..8fdb85e04 100644 --- a/loco-gen/src/templates/migration/add_columns.t +++ b/loco-gen/src/templates/migration/add_columns.t @@ -22,31 +22,33 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + {% for column in columns -%} manager .alter_table( alter({{tbl_enum}}::Table) - {% for column in columns -%} {% if column.1 == "decimal_len_null" or column.1 == "decimal_len" -%} .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case }}, 16, 4)) {% else -%} .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case}})) {% endif -%} - {% endfor -%} .to_owned(), ) - .await + .await?; + {% endfor -%} + Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + {% for column in columns -%} manager .alter_table( alter({{tbl_enum}}::Table) - {% for column in columns -%} .drop_column({{tbl_enum}}::{{column.0 | pascal_case}}) - {% endfor -%} .to_owned() ) - .await + .await?; + {% endfor -%} + Ok(()) } } diff --git a/loco-gen/src/templates/migration/remove_columns.t b/loco-gen/src/templates/migration/remove_columns.t index 1f6f959f4..c23c65864 100644 --- a/loco-gen/src/templates/migration/remove_columns.t +++ b/loco-gen/src/templates/migration/remove_columns.t @@ -22,31 +22,35 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + {% for column in columns -%} manager .alter_table( alter({{tbl_enum}}::Table) - {% for column in columns -%} + .drop_column({{tbl_enum}}::{{column.0 | pascal_case}}) - {% endfor -%} + .to_owned(), ) - .await + .await?; + {% endfor -%} + Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + {% for column in columns -%} manager .alter_table( alter({{tbl_enum}}::Table) - {% for column in columns -%} {% if column.1 == "decimal_len_null" or column.1 == "decimal_len" -%} .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case }}, 16, 4)) {% else -%} .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case}})) {% endif -%} - {% endfor -%} .to_owned(), ) - .await + .await?; + {% endfor -%} + Ok(()) } } diff --git a/loco-gen/src/templates/scaffold/html/view.t b/loco-gen/src/templates/scaffold/html/view.t index 932a117b0..fcf150fd4 100644 --- a/loco-gen/src/templates/scaffold/html/view.t +++ b/loco-gen/src/templates/scaffold/html/view.t @@ -12,7 +12,7 @@ use loco_rs::prelude::*; use crate::models::_entities::{{file_name | plural}}; -/// Render a list view of {{name | plural}}. +/// Render a list view of `{{name | plural}}`. /// /// # Errors /// @@ -21,7 +21,7 @@ pub fn list(v: &impl ViewRenderer, items: &Vec<{{file_name | plural}}::Model>) - format::render().view(v, "{{file_name}}/list.html", data!({"items": items})) } -/// Render a single {{name}} view. +/// Render a single `{{name}}` view. /// /// # Errors /// @@ -30,7 +30,7 @@ pub fn show(v: &impl ViewRenderer, item: &{{file_name | plural}}::Model) -> Resu format::render().view(v, "{{file_name}}/show.html", data!({"item": item})) } -/// Render a {{name }} create form. +/// Render a `{{name}}` create form. /// /// # Errors /// @@ -39,7 +39,7 @@ pub fn create(v: &impl ViewRenderer) -> Result { format::render().view(v, "{{file_name}}/create.html", data!({})) } -/// Render a {{name}} edit form. +/// Render a `{{name}}` edit form. /// /// # Errors /// diff --git a/loco-gen/src/templates/scaffold/htmx/view.t b/loco-gen/src/templates/scaffold/htmx/view.t index 932a117b0..fcf150fd4 100644 --- a/loco-gen/src/templates/scaffold/htmx/view.t +++ b/loco-gen/src/templates/scaffold/htmx/view.t @@ -12,7 +12,7 @@ use loco_rs::prelude::*; use crate::models::_entities::{{file_name | plural}}; -/// Render a list view of {{name | plural}}. +/// Render a list view of `{{name | plural}}`. /// /// # Errors /// @@ -21,7 +21,7 @@ pub fn list(v: &impl ViewRenderer, items: &Vec<{{file_name | plural}}::Model>) - format::render().view(v, "{{file_name}}/list.html", data!({"items": items})) } -/// Render a single {{name}} view. +/// Render a single `{{name}}` view. /// /// # Errors /// @@ -30,7 +30,7 @@ pub fn show(v: &impl ViewRenderer, item: &{{file_name | plural}}::Model) -> Resu format::render().view(v, "{{file_name}}/show.html", data!({"item": item})) } -/// Render a {{name }} create form. +/// Render a `{{name}}` create form. /// /// # Errors /// @@ -39,7 +39,7 @@ pub fn create(v: &impl ViewRenderer) -> Result { format::render().view(v, "{{file_name}}/create.html", data!({})) } -/// Render a {{name}} edit form. +/// Render a `{{name}}` edit form. /// /// # Errors /// diff --git a/loco-gen/src/templates/worker/test.t b/loco-gen/src/templates/worker/test.t index 8fc46954c..551a9016a 100644 --- a/loco-gen/src/templates/worker/test.t +++ b/loco-gen/src/templates/worker/test.t @@ -8,14 +8,13 @@ injections: append: true content: "pub mod {{ name | snake_case }};" --- -use {{pkg_name}}::app::App; use loco_rs::{bgworker::BackgroundWorker, testing::prelude::*}; - -use {{pkg_name}}::workers::{{module_name}}::{{struct_name}}Worker; -use {{pkg_name}}::workers::{{module_name}}::{{struct_name}}WorkerArgs; +use {{pkg_name}}::{ + app::App, + workers::{{module_name}}::{Worker, WorkerArgs}, +}; use serial_test::serial; - #[tokio::test] #[serial] async fn test_run_{{module_name}}_worker() { @@ -23,7 +22,7 @@ async fn test_run_{{module_name}}_worker() { // Execute the worker ensuring that it operates in 'ForegroundBlocking' mode, which prevents the addition of your worker to the background assert!( - {{struct_name}}Worker::perform_later(&boot.app_context, {{struct_name}}WorkerArgs {}) + Worker::perform_later(&boot.app_context,WorkerArgs {}) .await .is_ok() ); diff --git a/loco-gen/src/templates/worker/worker.t b/loco-gen/src/templates/worker/worker.t index 990a58d0a..06df6402a 100644 --- a/loco-gen/src/templates/worker/worker.t +++ b/loco-gen/src/templates/worker/worker.t @@ -9,25 +9,24 @@ injections: content: "pub mod {{ module_name}};" - into: src/app.rs after: "fn connect_workers" - content: " queue.register(crate::workers::{{module_name}}::{{struct_name}}Worker::build(ctx)).await?;" ---- + content: " queue.register(crate::workers::{{module_name}}::Worker::build(ctx)).await?;"--- use serde::{Deserialize, Serialize}; use loco_rs::prelude::*; -pub struct {{struct_name}}Worker { +pub struct Worker { pub ctx: AppContext, } #[derive(Deserialize, Debug, Serialize)] -pub struct {{struct_name}}WorkerArgs { +pub struct WorkerArgs { } #[async_trait] -impl BackgroundWorker<{{struct_name}}WorkerArgs> for {{struct_name}}Worker { +impl BackgroundWorker for Worker { fn build(ctx: &AppContext) -> Self { Self { ctx: ctx.clone() } } - async fn perform(&self, _args: {{struct_name}}WorkerArgs) -> Result<()> { + async fn perform(&self, _args: WorkerArgs) -> Result<()> { println!("================={{struct_name}}======================="); // TODO: Some actual work goes here... Ok(()) diff --git a/loco-new/Cargo.toml b/loco-new/Cargo.toml index 73b269a2a..03bee0710 100644 --- a/loco-new/Cargo.toml +++ b/loco-new/Cargo.toml @@ -50,7 +50,7 @@ uuid = { version = "1.11.0", features = ["v4", "fast-rng"] } serde_yaml = { version = "0.9" } insta = { version = "1.41.1", features = ["redactions", "yaml", "filters"] } rstest = { version = "0.23.0" } -tree-fs = "0.2.0" +tree-fs = "0.2.1" mockall = "0.13.0" toml = "0.8.19" regex = "1.11.1" diff --git a/loco-new/base_template/config/test.yaml.t b/loco-new/base_template/config/test.yaml.t index 267c60562..6d3f0ffb5 100644 --- a/loco-new/base_template/config/test.yaml.t +++ b/loco-new/base_template/config/test.yaml.t @@ -114,7 +114,7 @@ database: # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode dangerously_truncate: true # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode - dangerously_recreate: false + dangerously_recreate: true {%- endif %} {%- if settings.auth %} diff --git a/loco-new/tests/wizard/new.rs b/loco-new/tests/wizard/new.rs index f23cd80e7..eaf6a2c3a 100644 --- a/loco-new/tests/wizard/new.rs +++ b/loco-new/tests/wizard/new.rs @@ -1,5 +1,3 @@ -use std::{fs, path::PathBuf, sync::Arc}; - use duct::cmd; use loco::{ generator::{executer::FileSystem, Generator}, @@ -7,28 +5,7 @@ use loco::{ wizard::{self, AssetsOption, BackgroundOption, DBOption}, OS, }; -use uuid::Uuid; - -struct TestDir { - pub path: PathBuf, -} - -impl TestDir { - fn new() -> Self { - let path = std::env::temp_dir() - .join("loco-test-generator") - .join(Uuid::new_v4().to_string()); - - fs::create_dir_all(&path).unwrap(); - Self { path } - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} +use std::{collections::HashMap, path::PathBuf, process::Output, sync::Arc}; #[cfg(feature = "test-wizard")] #[rstest::rstest] @@ -44,121 +21,281 @@ fn test_all_combinations( #[values(AssetsOption::Serverside, AssetsOption::Clientside, AssetsOption::None)] asset: AssetsOption, ) { - test_combination(db, background, asset, false); + test_combination(db, background, asset); } // when running locally set LOCO_DEV_MODE_PATH= #[test] fn test_starter_combinations() { // lightweight service - test_combination( - DBOption::None, - BackgroundOption::None, - AssetsOption::None, - false, - ); + test_combination(DBOption::None, BackgroundOption::None, AssetsOption::None); // REST API test_combination( DBOption::Sqlite, BackgroundOption::Async, AssetsOption::None, - true, ); // SaaS, serverside test_combination( DBOption::Sqlite, BackgroundOption::Async, AssetsOption::Serverside, - true, ); // SaaS, clientside test_combination( DBOption::Sqlite, BackgroundOption::Async, AssetsOption::Clientside, - true, ); // test only DB - test_combination( - DBOption::Sqlite, - BackgroundOption::None, - AssetsOption::None, - true, - ); + test_combination(DBOption::Sqlite, BackgroundOption::None, AssetsOption::None); } -fn test_combination( - db: DBOption, - background: BackgroundOption, - asset: AssetsOption, - scaffold: bool, -) { - use std::collections::HashMap; - - let test_dir = TestDir::new(); +fn test_combination(db: DBOption, background: BackgroundOption, asset: AssetsOption) { + let test_dir = tree_fs::TreeBuilder::default().drop(true); - let executor = FileSystem::new(&PathBuf::from("base_template"), &test_dir.path); + let executor = FileSystem::new(&PathBuf::from("base_template"), &test_dir.root); let wizard_selection = wizard::Selections { - db, - background, + db: db.clone(), + background: background.clone(), asset, }; let settings = settings::Settings::from_wizard("test-loco-template", &wizard_selection, OS::default()); - let res = Generator::new(Arc::new(executor), settings).run(); + let res = Generator::new(Arc::new(executor), settings.clone()).run(); assert!(res.is_ok()); let mut env_map: HashMap<_, _> = std::env::vars().collect(); env_map.insert("RUSTFLAGS".into(), "-D warnings".into()); - assert!(cmd!( - "cargo", - "clippy", - "--quiet", - "--", - "-W", - "clippy::pedantic", - "-W", - "clippy::nursery", - "-W", - "rust-2018-idioms" - ) - .full_env(&env_map) - // .stdout_null() - // .stderr_null() - .dir(test_dir.path.as_path()) - .run() - .is_ok()); - - cmd!("cargo", "test") - // .stdout_null() - // .stderr_null() - .full_env(&env_map) - .dir(test_dir.path.as_path()) - .run() - .expect("run test"); - if scaffold { - cmd!( - "cargo", - "loco", - "g", + let tester = Tester { + dir: test_dir.root, + env_map, + }; + + tester + .run_clippy() + .expect("run clippy after create new project"); + + tester + .run_test() + .expect("run test after create new project"); + + // Generate API controller + tester.run_generate(&vec![ + "controller", + "notes_api", + "--api", + "create_note", + "get_note", + ]); + + // Generate HTMX controller + tester.run_generate(&vec![ + "controller", + "notes_htmx", + "--htmx", + "create_note", + "get_note", + ]); + + // Generate HTML controller + tester.run_generate(&vec![ + "controller", + "notes_html", + "--html", + "create_note", + "get_note", + ]); + + // Generate Task + tester.run_generate(&vec!["task", "list_users"]); + + // Generate Scheduler + tester.run_generate(&vec!["scheduler"]); + + if background.enable() { + // Generate Worker + tester.run_generate(&vec!["worker", "cleanup"]); + } + + if settings.mailer { + // Generate Mailer + tester.run_generate(&vec!["mailer", "user_mailer"]); + } + + // Generate deployment nginx + tester.run_generate(&vec!["deployment", "--kind", "nginx"]); + + // Generate deployment nginx + tester.run_generate(&vec!["deployment", "--kind", "docker"]); + + if db.enable() { + // Generate Model + if !settings.auth { + tester.run_generate(&vec!["model", "users", "name:string", "email:string"]); + } + tester.run_generate(&vec!["model", "movies", "title:string", "user:references"]); + + // Generate HTMX Scaffold + tester.run_generate(&vec![ + "scaffold", + "movies_htmx", + "title:string", + "user:references", + "--htmx", + ]); + + // Generate HTML Scaffold + tester.run_generate(&vec![ "scaffold", - "movie", + "movies_html", "title:string", - "--htmx" + "user:references", + "--html", + ]); + + // Generate API Scaffold + tester.run_generate(&vec![ + "scaffold", + "movies_api", + "title:string", + "user:references", + "--api", + ]); + + // Generate CreatePosts migration + tester.run_generate_migration(&vec![ + "CreatePosts", + "title:string", + "user:references", + "movies:references", + ]); + + // Generate AddNameAndAgeToUsers migration + tester.run_generate_migration(&vec![ + "AddNameAndAgeToUsers", + "first_name:string", + "age:int", + ]); + + // Generate AddNameAndAgeToUsers migration + tester.run_generate_migration(&vec![ + "RemoveNameAndAgeFromUsers", + "first_name:string", + "age:int", + ]); + + // Generate AddUserRefToPosts migration + // TODO:: not working on sqlite. + // - thread 'main' panicked at 'Sqlite doesn't support multiple alter options' + // - Sqlite does not support modification of foreign key constraints to existing + // tester.run_generate_migration(&vec!["AddUserRefToPosts", "movies:references"]); + + // Generate CreateJoinTableUsersAndGroups migration + tester.run_generate_migration(&vec!["CreateJoinTableUsersAndGroups", "count:int"]); + } +} + +struct Tester { + dir: PathBuf, + env_map: HashMap, +} + +impl Tester { + fn run_clippy(&self) -> Result { + cmd!( + "cargo", + "clippy", + "--quiet", + "--", + "-W", + "clippy::pedantic", + "-W", + "clippy::nursery", + "-W", + "rust-2018-idioms" ) - .full_env(&env_map) - .dir(test_dir.path.as_path()) + .full_env(&self.env_map) + // .stdout_null() + // .stderr_null() + .dir(&self.dir) .run() - .expect("scaffold"); + } + + fn run_test(&self) -> Result { cmd!("cargo", "test") // .stdout_null() // .stderr_null() - .full_env(&env_map) - .dir(test_dir.path.as_path()) + .full_env(&self.env_map) + .dir(&self.dir) + .run() + } + + fn run_migrate(&self) -> Result { + cmd!("cargo", "loco", "db", "migrate") + // .stdout_null() + // .stderr_null() + .full_env(&self.env_map) + .dir(&self.dir) + .run() + } + + fn run_generate(&self, command: &Vec<&str>) { + let base_command = vec!["loco", "generate"]; + + // Concatenate base_command with the command vector + let mut args = base_command.clone(); + args.extend(command); + + duct::cmd("cargo", &args) + // .stdout_null() + // .stderr_null() + .full_env(&self.env_map) + .dir(&self.dir) + .run() + .unwrap_or_else(|_| panic!("generate `{}`", command.join(" "))); + + self.run_clippy() + .unwrap_or_else(|_| panic!("Run clippy after generate `{}`", command.join(" "))); + + self.run_test() + .unwrap_or_else(|_| panic!("Run Test after generate `{}`", command.join(" "))); + } + + fn run_generate_migration(&self, command: &Vec<&str>) { + let base_command = vec!["loco", "generate", "migration"]; + + // Concatenate base_command with the command vector + let mut args = base_command.clone(); + args.extend(command); + + duct::cmd("cargo", &args) + // .stdout_null() + // .stderr_null() + .full_env(&self.env_map) + .dir(&self.dir) .run() - .expect("test after scaffold"); + .unwrap_or_else(|_| panic!("generate `{}`", command.join(" "))); + + self.run_migrate().unwrap_or_else(|_| { + panic!( + "Run migrate after creating the migration `{}`", + command.join(" ") + ) + }); + + self.run_clippy().unwrap_or_else(|_| { + panic!( + "Run clippy after generate migration `{}`", + command.join(" ") + ) + }); + + self.run_test().unwrap_or_else(|_| { + panic!("Run Test after generate migration `{}`", command.join(" ")) + }); } } diff --git a/src/cli.rs b/src/cli.rs index 8fc4564d4..cceacd458 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,15 +30,17 @@ cfg_if::cfg_if! { feature = "with-db" ))] use std::process::exit; - -use std::{collections::BTreeMap, path::PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; #[cfg(any(feature = "bg_redis", feature = "bg_pg", feature = "bg_sqlt"))] use crate::bgworker::JobStatus; use clap::{ArgAction, Parser, Subcommand}; use colored::Colorize; use duct::cmd; -use loco_gen::{Component, ScaffoldKind}; +use loco_gen::{Component, DeploymentKind, ScaffoldKind}; use crate::{ app::{AppContext, Hooks}, @@ -264,7 +266,21 @@ enum ComponentArg { name: String, }, /// Generate a deployment infrastructure - Deployment {}, + Deployment { + // deployment kind. + #[clap(long, value_enum)] + kind: DeploymentKind, + }, + + /// Override templates and allows you to take control of them. You can always go back when deleting the local template. + Override { + /// The path to a specific template or directory to copy. + template_path: Option, + + /// Show available templates to copy under the specified directory without actually coping them. + #[arg(long, action)] + info: bool, + }, } impl ComponentArg { @@ -331,7 +347,7 @@ impl ComponentArg { Self::Scheduler {} => Ok(Component::Scheduler {}), Self::Worker { name } => Ok(Component::Worker { name }), Self::Mailer { name } => Ok(Component::Mailer { name }), - Self::Deployment {} => { + Self::Deployment { kind } => { let copy_asset_folder = &config .server .middlewares @@ -347,12 +363,19 @@ impl ComponentArg { .map(|a| a.fallback); Ok(Component::Deployment { + kind, asset_folder: copy_asset_folder.clone(), fallback_file: fallback_file.clone(), host: config.server.host.clone(), port: config.server.port, }) } + Self::Override { + template_path: _, + info: _, + } => Err(crate::Error::string( + "Error: Override could not be generated.", + )), } } } @@ -531,9 +554,6 @@ pub async fn playground() -> crate::Result { #[allow(clippy::too_many_lines)] #[allow(clippy::cognitive_complexity)] pub async fn main() -> crate::Result<()> { - use colored::Colorize; - use loco_gen::AppInfo; - let cli: Cli = Cli::parse(); let environment: Environment = cli.environment.unwrap_or_else(resolve_from_env).into(); @@ -619,12 +639,7 @@ pub async fn main() -> crate::Result<()> { run_scheduler::(&app_context, config.as_ref(), name, tag, list).await?; } Commands::Generate { component } => { - loco_gen::generate( - component.into_gen_component(&config)?, - &AppInfo { - app_name: H::app_name().to_string(), - }, - )?; + handle_generate_command::(component, &config)?; } Commands::Doctor { config: config_arg, @@ -677,9 +692,6 @@ pub async fn main() -> crate::Result<()> { #[cfg(not(feature = "with-db"))] pub async fn main() -> crate::Result<()> { - use colored::Colorize; - use loco_gen::AppInfo; - let cli = Cli::parse(); let environment: Environment = cli.environment.unwrap_or_else(resolve_from_env).into(); @@ -758,12 +770,7 @@ pub async fn main() -> crate::Result<()> { run_scheduler::(&app_context, config.as_ref(), name, tag, list).await?; } Commands::Generate { component } => { - loco_gen::generate( - component.into_gen_component(&config)?, - &AppInfo { - app_name: H::app_name().to_string(), - }, - )?; + handle_generate_command::(component, &config)?; } Commands::Version {} => { println!("{}", H::app_version(),); @@ -954,3 +961,93 @@ async fn handle_job_command( JobsCommands::Import { file } => queue.import(file.as_path()).await, } } + +fn handle_generate_command( + component: ComponentArg, + config: &Config, +) -> crate::Result<()> { + if let ComponentArg::Override { + template_path, + info, + } = component + { + match (template_path, info) { + // If no template path is provided, display the available templates, + // ignoring the `--info` flag. + (None, true | false) => { + let templates = loco_gen::template::collect(); + println!("{}", format_templates(templates)); + } + // If a template path is provided and `--info` is enabled, + // display the templates from the specified path. + (Some(path), true) => { + let templates = loco_gen::template::collect_files_path(Path::new(&path)).unwrap(); + println!("{}", format_templates(templates)); + } + // If a template path is provided and `--info` is disabled, + // copy the template to the default local template path. + (Some(path), false) => loco_gen::copy_template( + Path::new(&path), + Path::new(loco_gen::template::DEFAULT_LOCAL_TEMPLATE), + ) + .unwrap(), + } + } else { + let get_result = loco_gen::generate( + &loco_gen::RRgen::default(), + component.into_gen_component(config)?, + &loco_gen::AppInfo { + app_name: H::app_name().to_string(), + }, + )?; + let messages = loco_gen::collect_messages(&get_result); + println!("{messages}"); + }; + Ok(()) +} + +#[must_use] +pub fn format_templates(paths: Vec) -> String { + let mut categories: BTreeMap>> = BTreeMap::new(); + + for path in paths { + if let Some(parent) = path.parent() { + let mut components = parent.components(); + if let Some(top_level) = components.next() { + let top_key = top_level.as_os_str().to_string_lossy().to_string(); + let sub_key = components + .next() + .map_or_else(String::new, |c| c.as_os_str().to_string_lossy().to_string()); + + categories + .entry(top_key) + .or_default() + .entry(sub_key) + .or_default() + .push(path); + } + } + } + + let mut output = String::new(); + output.push_str("Available templates and directories to copy:\n\n"); + for (top_level, sub_categories) in &categories { + for (sub_category, paths) in sub_categories { + if sub_category.is_empty() { + output.push_str(&format!("{}:\n", top_level.to_uppercase().green(),)); + } else { + output.push_str(&format!( + "{} {}:\n", + top_level.to_uppercase().green(), + sub_category.to_uppercase().green(), + )); + } + + for path in paths { + output.push_str(&format!(" {}\n", path.display())); + } + output.push('\n'); + } + } + output +} diff --git a/src/logger.rs b/src/logger.rs index 8bcd53c0f..651d53f6e 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -76,6 +76,7 @@ const MODULE_WHITELIST: &[&str] = &[ "sqlx::query", "sidekiq", "playground", + "loco_gen", ]; // Keep nonblocking file appender work guard