// this is because not using with-db renders some of the structs below unused // TODO: should be more properly aligned with extracting out the db-related gen // code and then feature toggling it #![allow(dead_code)] pub use rrgen::{GenResult, RRgen}; use serde_json::{json, Value}; mod controller; use colored::Colorize; use std::fmt::Write; use std::{ fs, path::{Path, PathBuf}, }; pub mod column; #[cfg(feature = "with-db")] mod infer; #[cfg(feature = "with-db")] mod migration; #[cfg(feature = "with-db")] mod model; #[cfg(feature = "with-db")] mod scaffold; pub mod template; #[cfg(test)] mod testutil; #[derive(Debug)] pub struct GenerateResults { rrgen: Vec, local_templates: Vec, } #[derive(thiserror::Error, Debug)] pub enum Error { #[error("{0}")] Message(String), #[error("template {} not found", path.display())] TemplateNotFound { path: PathBuf }, #[error(transparent)] RRgen(#[from] rrgen::Error), #[error(transparent)] IO(#[from] std::io::Error), #[error(transparent)] Any(#[from] Box), } impl Error { pub fn msg(err: impl std::error::Error + Send + Sync + 'static) -> Self { Self::Message(err.to_string()) //.bt() } } pub type Result = std::result::Result; #[derive(Debug, Clone)] pub enum DeploymentKind { Docker { copy_paths: Vec, is_client_side_rendering: bool, }, Nginx { host: String, port: i32, }, } #[derive(Debug)] pub enum Component { #[cfg(feature = "with-db")] Model { /// Name of the thing to generate name: String, /// Whether to include timestamps (`created_at``updated_at`at columns) in the model with_tz: bool, /// Model fields, eg. title:string hits:int fields: Vec<(String, String)>, }, #[cfg(feature = "with-db")] Migration { /// Name of the migration file name: String, /// Whether to include timestamps (`created_at`, `updated_at` columns) in the migration with_tz: bool, /// Params fields, eg. title:string hits:int fields: Vec<(String, String)>, }, #[cfg(feature = "with-db")] Scaffold { /// Name of the thing to generate name: String, /// Whether to include timestamps (`created_at``updated_at`at columns) in the scaffold with_tz: bool, /// Model and params fields, eg. title:string hits:int fields: Vec<(String, String)>, /// Whether to also emit the React-SPA frontend (hooks + pages + routes /// injection). Set when the app has a clientside `frontend/`; when /// `false` only the typed backend (DTO + controller) is emitted. frontend: bool, }, Controller { /// Name of the thing to generate name: String, /// Action names actions: Vec, }, Task { /// Name of the thing to generate name: String, }, Scheduler {}, Worker { /// Name of the thing to generate name: String, }, Mailer { /// Name of the thing to generate name: String, }, Data { /// Name of the thing to generate name: String, }, Deployment { kind: DeploymentKind, }, } pub struct AppInfo { pub app_name: String, } #[must_use] pub fn new_generator() -> RRgen { RRgen::default() } /// Generate a component /// /// # Errors /// /// This function will return an error if it fails pub fn generate(rrgen: &RRgen, component: Component, appinfo: &AppInfo) -> Result { /* (1) XXX: remove hooks generic from child generator, materialize it here and pass it means each generator accepts a [component, config, context] tuple this will allow us to test without an app instance (2) proceed to test individual generators */ let get_result = match component { #[cfg(feature = "with-db")] Component::Model { name, with_tz, fields, } => model::generate(rrgen, &name, with_tz, &fields, appinfo)?, #[cfg(feature = "with-db")] Component::Scaffold { name, with_tz, fields, frontend, } => scaffold::generate(rrgen, &name, with_tz, &fields, frontend, appinfo)?, #[cfg(feature = "with-db")] Component::Migration { name, with_tz, fields, } => migration::generate(rrgen, &name, with_tz, &fields, appinfo)?, Component::Controller { name, actions } => { controller::generate(rrgen, &name, &actions, appinfo)? } Component::Task { name } => { let vars = json!({"name": name, "pkg_name": appinfo.app_name}); render_template(rrgen, Path::new("task"), &vars)? } Component::Scheduler {} => { let vars = json!({"pkg_name": appinfo.app_name}); render_template(rrgen, Path::new("scheduler"), &vars)? } Component::Worker { name } => { let vars = json!({"name": name, "pkg_name": appinfo.app_name}); render_template(rrgen, Path::new("worker"), &vars)? } Component::Mailer { name } => { let vars = json!({ "name": name }); render_template(rrgen, Path::new("mailer"), &vars)? } Component::Deployment { kind } => match kind { DeploymentKind::Docker { copy_paths, is_client_side_rendering, } => { let vars = json!({ "pkg_name": appinfo.app_name, "copy_paths": copy_paths, "is_client_side_rendering": is_client_side_rendering, }); render_template(rrgen, Path::new("deployment/docker"), &vars)? } DeploymentKind::Nginx { host, port } => { 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)? } }, Component::Data { name } => { let vars = json!({ "name": name }); render_template(rrgen, Path::new("data"), &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![]; let mut local_templates = 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).inspect_err(|_err| { tracing::error!(custom_template = %custom_template.display(), "could not read custom template"); })?; gen_result.push(rrgen.generate(&content, vars)?); local_templates.push(custom_template); } 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(GenerateResults { rrgen: gen_result, local_templates, }) } #[must_use] pub fn collect_messages(results: &GenerateResults) -> String { let mut messages = String::new(); for res in &results.rrgen { if let rrgen::GenResult::Generated { message: Some(message), } = res { let _ = writeln!(messages, "* {message}"); } } if !results.local_templates.is_empty() { let _ = writeln!(messages); let _ = writeln!( messages, "{}", "The following templates were sourced from the local templates:".green() ); for f in &results.local_templates { let _ = writeln!(messages, "* {}", f.display()); } } messages } /// 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() }; let mut copied_files = vec![]; 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" ); copied_files.push(copy_to); } Ok(copied_files) } #[cfg(test)] mod tests { use std::path::Path; use super::*; #[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"); } } #[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"); 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:?}" ); } } #[test] fn can_collect_messages() { let gen_result = GenerateResults { rrgen: vec![ GenResult::Skipped, GenResult::Generated { message: Some("test".to_string()), }, GenResult::Generated { message: Some("test2".to_string()), }, GenResult::Generated { message: None }, ], local_templates: vec![ PathBuf::from("template").join("scheduler.t"), PathBuf::from("template").join("task.t"), ], }; let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap(); assert_eq!( re.replace_all(&collect_messages(&gen_result), ""), r"* test * test2 The following templates were sourced from the local templates: * template/scheduler.t * template/task.t " ); } }