445 lines
13 KiB
Rust
445 lines
13 KiB
Rust
// 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<rrgen::GenResult>,
|
|
local_templates: Vec<PathBuf>,
|
|
}
|
|
|
|
#[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<dyn std::error::Error + Send + Sync>),
|
|
}
|
|
|
|
impl Error {
|
|
pub fn msg(err: impl std::error::Error + Send + Sync + 'static) -> Self {
|
|
Self::Message(err.to_string()) //.bt()
|
|
}
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, Error>;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum DeploymentKind {
|
|
Docker {
|
|
copy_paths: Vec<PathBuf>,
|
|
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<String>,
|
|
},
|
|
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<GenerateResults> {
|
|
/*
|
|
(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<GenerateResults> {
|
|
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<Vec<PathBuf>> {
|
|
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
|
|
"
|
|
);
|
|
}
|
|
}
|