loco_gen/
lib.rs

1// this is because not using with-db renders some of the structs below unused
2// TODO: should be more properly aligned with extracting out the db-related gen
3// code and then feature toggling it
4#![allow(dead_code)]
5pub use rrgen::{GenResult, RRgen};
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8mod controller;
9use colored::Colorize;
10use std::fmt::Write;
11use std::{
12    collections::HashMap,
13    fs,
14    path::{Path, PathBuf},
15    sync::OnceLock,
16};
17
18#[cfg(feature = "with-db")]
19mod infer;
20#[cfg(feature = "with-db")]
21mod migration;
22#[cfg(feature = "with-db")]
23mod model;
24#[cfg(feature = "with-db")]
25mod scaffold;
26pub mod template;
27pub mod tera_ext;
28#[cfg(test)]
29mod testutil;
30
31#[derive(Debug)]
32pub struct GenerateResults {
33    rrgen: Vec<rrgen::GenResult>,
34    local_templates: Vec<PathBuf>,
35}
36pub const DEPLOYMENT_SHUTTLE_RUNTIME_VERSION: &str = "0.56.0";
37
38#[derive(thiserror::Error, Debug)]
39pub enum Error {
40    #[error("{0}")]
41    Message(String),
42    #[error("template {} not found", path.display())]
43    TemplateNotFound { path: PathBuf },
44    #[error(transparent)]
45    RRgen(#[from] rrgen::Error),
46    #[error(transparent)]
47    IO(#[from] std::io::Error),
48    #[error(transparent)]
49    Any(#[from] Box<dyn std::error::Error + Send + Sync>),
50}
51
52impl Error {
53    pub fn msg(err: impl std::error::Error + Send + Sync + 'static) -> Self {
54        Self::Message(err.to_string()) //.bt()
55    }
56}
57
58pub type Result<T> = std::result::Result<T, Error>;
59
60#[derive(Serialize, Deserialize, Debug)]
61struct FieldType {
62    name: String,
63    rust: RustType,
64    schema: String,
65    col_type: String,
66    #[serde(default)]
67    arity: usize,
68}
69
70#[derive(Debug, Deserialize, Serialize)]
71#[serde(untagged)]
72pub enum RustType {
73    String(String),
74    Map(HashMap<String, String>),
75}
76
77#[derive(Serialize, Deserialize, Debug)]
78pub struct Mappings {
79    field_types: Vec<FieldType>,
80}
81impl Mappings {
82    fn error_unrecognized_default_field(&self, field: &str) -> Error {
83        Self::error_unrecognized(field, &self.all_names())
84    }
85
86    fn error_unrecognized(field: &str, allow_fields: &[&String]) -> Error {
87        Error::Message(format!(
88            "type: `{}` not found. try any of: `{}`",
89            field,
90            allow_fields
91                .iter()
92                .map(|&s| s.to_string())
93                .collect::<Vec<String>>()
94                .join(",")
95        ))
96    }
97
98    /// Resolves the Rust type for a given field with optional parameters.
99    ///
100    /// # Errors
101    ///
102    /// if rust field not exists or invalid parameters
103    pub fn rust_field_with_params(&self, field: &str, params: &Vec<String>) -> Result<&str> {
104        match field {
105            "array" | "array^" | "array!" => {
106                if let RustType::Map(ref map) = self.rust_field_kind(field)? {
107                    if let [single] = params.as_slice() {
108                        let keys: Vec<&String> = map.keys().collect();
109                        Ok(map
110                            .get(single)
111                            .ok_or_else(|| Self::error_unrecognized(field, &keys))?)
112                    } else {
113                        Err(self.error_unrecognized_default_field(field))
114                    }
115                } else {
116                    Err(Error::Message(
117                        "array field should configured as array".to_owned(),
118                    ))
119                }
120            }
121
122            _ => self.rust_field(field),
123        }
124    }
125
126    /// Resolves the Rust type for a given field.
127    ///
128    /// # Errors
129    ///
130    /// When the given field not recognized
131    pub fn rust_field_kind(&self, field: &str) -> Result<&RustType> {
132        self.field_types
133            .iter()
134            .find(|f| f.name == field)
135            .map(|f| &f.rust)
136            .ok_or_else(|| self.error_unrecognized_default_field(field))
137    }
138
139    /// Resolves the Rust type for a given field.
140    ///
141    /// # Errors
142    ///
143    /// When the given field not recognized
144    pub fn rust_field(&self, field: &str) -> Result<&str> {
145        self.field_types
146            .iter()
147            .find(|f| f.name == field)
148            .map(|f| &f.rust)
149            .ok_or_else(|| self.error_unrecognized_default_field(field))
150            .and_then(|rust_type| match rust_type {
151                RustType::String(s) => Ok(s),
152                RustType::Map(_) => Err(Error::Message(format!(
153                    "type `{field}` need params to get the rust field type"
154                ))),
155            })
156            .map(std::string::String::as_str)
157    }
158
159    /// Retrieves the schema field associated with the given field.
160    ///
161    /// # Errors
162    ///
163    /// When the given field not recognized
164    pub fn schema_field(&self, field: &str) -> Result<&str> {
165        self.field_types
166            .iter()
167            .find(|f| f.name == field)
168            .map(|f| f.schema.as_str())
169            .ok_or_else(|| self.error_unrecognized_default_field(field))
170    }
171
172    /// Retrieves the column type field associated with the given field.
173    ///
174    /// # Errors
175    ///
176    /// When the given field not recognized
177    pub fn col_type_field(&self, field: &str) -> Result<&str> {
178        self.field_types
179            .iter()
180            .find(|f| f.name == field)
181            .map(|f| f.col_type.as_str())
182            .ok_or_else(|| self.error_unrecognized_default_field(field))
183    }
184
185    /// Retrieves the column type arity associated with the given field.
186    ///
187    /// # Errors
188    ///
189    /// When the given field not recognized
190    pub fn col_type_arity(&self, field: &str) -> Result<usize> {
191        self.field_types
192            .iter()
193            .find(|f| f.name == field)
194            .map(|f| f.arity)
195            .ok_or_else(|| self.error_unrecognized_default_field(field))
196    }
197
198    #[must_use]
199    pub fn all_names(&self) -> Vec<&String> {
200        self.field_types.iter().map(|f| &f.name).collect::<Vec<_>>()
201    }
202}
203
204static MAPPINGS: OnceLock<Mappings> = OnceLock::new();
205
206/// Get type mapping for generation
207///
208/// # Panics
209///
210/// Panics if loading fails
211pub fn get_mappings() -> &'static Mappings {
212    MAPPINGS.get_or_init(|| {
213        let json_data = include_str!("./mappings.json");
214        serde_json::from_str(json_data).expect("JSON was not well-formatted")
215    })
216}
217
218#[derive(clap::ValueEnum, Clone, Debug)]
219pub enum ScaffoldKind {
220    Api,
221    Html,
222    Htmx,
223}
224
225#[derive(Debug, Clone)]
226pub enum DeploymentKind {
227    Docker {
228        copy_paths: Vec<PathBuf>,
229        is_client_side_rendering: bool,
230    },
231    Shuttle {
232        runttime_version: Option<String>,
233    },
234    Nginx {
235        host: String,
236        port: i32,
237    },
238}
239
240#[derive(Debug)]
241pub enum Component {
242    #[cfg(feature = "with-db")]
243    Model {
244        /// Name of the thing to generate
245        name: String,
246
247        /// Whether to include timestamps (`created_at``updated_at`at columns) in the model
248        with_tz: bool,
249
250        /// Model fields, eg. title:string hits:int
251        fields: Vec<(String, String)>,
252    },
253    #[cfg(feature = "with-db")]
254    Migration {
255        /// Name of the migration file
256        name: String,
257
258        /// Whether to include timestamps (`created_at`, `updated_at` columns) in the migration
259        with_tz: bool,
260
261        /// Params fields, eg. title:string hits:int
262        fields: Vec<(String, String)>,
263    },
264    #[cfg(feature = "with-db")]
265    Scaffold {
266        /// Name of the thing to generate
267        name: String,
268
269        /// Whether to include timestamps (`created_at``updated_at`at columns) in the scaffold
270        with_tz: bool,
271
272        /// Model and params fields, eg. title:string hits:int
273        fields: Vec<(String, String)>,
274
275        // k
276        kind: ScaffoldKind,
277    },
278    Controller {
279        /// Name of the thing to generate
280        name: String,
281
282        /// Action names
283        actions: Vec<String>,
284
285        // kind
286        kind: ScaffoldKind,
287    },
288    Task {
289        /// Name of the thing to generate
290        name: String,
291    },
292    Scheduler {},
293    Worker {
294        /// Name of the thing to generate
295        name: String,
296    },
297    Mailer {
298        /// Name of the thing to generate
299        name: String,
300    },
301    Data {
302        /// Name of the thing to generate
303        name: String,
304    },
305    Deployment {
306        kind: DeploymentKind,
307    },
308}
309
310pub struct AppInfo {
311    pub app_name: String,
312}
313
314#[must_use]
315pub fn new_generator() -> RRgen {
316    RRgen::default().add_template_engine(tera_ext::new())
317}
318
319/// Generate a component
320///
321/// # Errors
322///
323/// This function will return an error if it fails
324pub fn generate(rrgen: &RRgen, component: Component, appinfo: &AppInfo) -> Result<GenerateResults> {
325    /*
326    (1)
327    XXX: remove hooks generic from child generator, materialize it here and pass it
328         means each generator accepts a [component, config, context] tuple
329         this will allow us to test without an app instance
330    (2) proceed to test individual generators
331     */
332    let get_result = match component {
333        #[cfg(feature = "with-db")]
334        Component::Model {
335            name,
336            with_tz,
337            fields,
338        } => model::generate(rrgen, &name, with_tz, &fields, appinfo)?,
339        #[cfg(feature = "with-db")]
340        Component::Scaffold {
341            name,
342            with_tz,
343            fields,
344            kind,
345        } => scaffold::generate(rrgen, &name, with_tz, &fields, &kind, appinfo)?,
346        #[cfg(feature = "with-db")]
347        Component::Migration {
348            name,
349            with_tz,
350            fields,
351        } => migration::generate(rrgen, &name, with_tz, &fields, appinfo)?,
352        Component::Controller {
353            name,
354            actions,
355            kind,
356        } => controller::generate(rrgen, &name, &actions, &kind, appinfo)?,
357        Component::Task { name } => {
358            let vars = json!({"name": name, "pkg_name": appinfo.app_name});
359            render_template(rrgen, Path::new("task"), &vars)?
360        }
361        Component::Scheduler {} => {
362            let vars = json!({"pkg_name": appinfo.app_name});
363            render_template(rrgen, Path::new("scheduler"), &vars)?
364        }
365        Component::Worker { name } => {
366            let vars = json!({"name": name, "pkg_name": appinfo.app_name});
367            render_template(rrgen, Path::new("worker"), &vars)?
368        }
369        Component::Mailer { name } => {
370            let vars = json!({ "name": name });
371            render_template(rrgen, Path::new("mailer"), &vars)?
372        }
373        Component::Deployment { kind } => match kind {
374            DeploymentKind::Docker {
375                copy_paths,
376                is_client_side_rendering,
377            } => {
378                let vars = json!({
379                    "pkg_name": appinfo.app_name,
380                    "copy_paths": copy_paths,
381                    "is_client_side_rendering": is_client_side_rendering,
382                });
383                render_template(rrgen, Path::new("deployment/docker"), &vars)?
384            }
385            DeploymentKind::Shuttle { runttime_version } => {
386                let vars = json!({
387                    "pkg_name": appinfo.app_name,
388                    "shuttle_runtime_version": runttime_version.unwrap_or_else( || DEPLOYMENT_SHUTTLE_RUNTIME_VERSION.to_string()),
389                    "with_db": cfg!(feature = "with-db")
390                });
391
392                render_template(rrgen, Path::new("deployment/shuttle"), &vars)?
393            }
394            DeploymentKind::Nginx { host, port } => {
395                let host = host.replace("http://", "").replace("https://", "");
396                let vars = json!({
397                    "pkg_name": appinfo.app_name,
398                    "domain": host,
399                    "port": port
400                });
401                render_template(rrgen, Path::new("deployment/nginx"), &vars)?
402            }
403        },
404        Component::Data { name } => {
405            let vars = json!({ "name": name });
406            render_template(rrgen, Path::new("data"), &vars)?
407        }
408    };
409
410    Ok(get_result)
411}
412
413fn render_template(rrgen: &RRgen, template: &Path, vars: &Value) -> Result<GenerateResults> {
414    let template_files = template::collect_files_from_path(template)?;
415
416    let mut gen_result = vec![];
417    let mut local_templates = vec![];
418    for template in template_files {
419        let custom_template = Path::new(template::DEFAULT_LOCAL_TEMPLATE).join(template.path());
420
421        if custom_template.exists() {
422            let content = fs::read_to_string(&custom_template).map_err(|err| {
423                tracing::error!(custom_template = %custom_template.display(), "could not read custom template");
424                err
425            })?;
426            gen_result.push(rrgen.generate(&content, vars)?);
427            local_templates.push(custom_template);
428        } else {
429            let content = template.contents_utf8().ok_or(Error::Message(format!(
430                "could not get template content: {}",
431                template.path().display()
432            )))?;
433            gen_result.push(rrgen.generate(content, vars)?);
434        }
435    }
436
437    Ok(GenerateResults {
438        rrgen: gen_result,
439        local_templates,
440    })
441}
442
443#[must_use]
444pub fn collect_messages(results: &GenerateResults) -> String {
445    let mut messages = String::new();
446
447    for res in &results.rrgen {
448        if let rrgen::GenResult::Generated {
449            message: Some(message),
450        } = res
451        {
452            let _ = writeln!(messages, "* {message}");
453        }
454    }
455
456    if !results.local_templates.is_empty() {
457        let _ = writeln!(messages);
458        let _ = writeln!(
459            messages,
460            "{}",
461            "The following templates were sourced from the local templates:".green()
462        );
463
464        for f in &results.local_templates {
465            let _ = writeln!(messages, "* {}", f.display());
466        }
467    }
468    messages
469}
470
471/// Copies template files to a specified destination directory.
472///
473/// This function copies files from the specified template path to the
474/// destination directory. If the specified path is `/` or `.`, it copies all
475/// files from the templates directory. If the path does not exist in the
476/// templates, it returns an error.
477///
478/// # Errors
479/// when could not copy the given template path
480pub fn copy_template(path: &Path, to: &Path) -> Result<Vec<PathBuf>> {
481    let copy_template_path = if path == Path::new("/") || path == Path::new(".") {
482        None
483    } else if !template::exists(path) {
484        return Err(Error::TemplateNotFound {
485            path: path.to_path_buf(),
486        });
487    } else {
488        Some(path)
489    };
490
491    let copy_files = if let Some(path) = copy_template_path {
492        template::collect_files_from_path(path)?
493    } else {
494        template::collect_files()
495    };
496
497    let mut copied_files = vec![];
498    for f in copy_files {
499        let copy_to = to.join(f.path());
500        if copy_to.exists() {
501            tracing::debug!(
502                template_file = %copy_to.display(),
503                "skipping copy template file. already exists"
504            );
505            continue;
506        }
507        match copy_to.parent() {
508            Some(parent) => {
509                fs::create_dir_all(parent)?;
510            }
511            None => {
512                return Err(Error::Message(format!(
513                    "could not get parent folder of {}",
514                    copy_to.display()
515                )))
516            }
517        }
518
519        fs::write(&copy_to, f.contents())?;
520        tracing::trace!(
521            template = %copy_to.display(),
522            "copy template successfully"
523        );
524        copied_files.push(copy_to);
525    }
526    Ok(copied_files)
527}
528
529#[cfg(test)]
530mod tests {
531    use std::path::Path;
532
533    use super::*;
534
535    #[test]
536    fn test_template_not_found() {
537        let tree_fs = tree_fs::TreeBuilder::default()
538            .drop(true)
539            .create()
540            .expect("create temp file");
541        let path = Path::new("nonexistent-template");
542
543        let result = copy_template(path, tree_fs.root.as_path());
544        assert!(result.is_err());
545        if let Err(Error::TemplateNotFound { path: p }) = result {
546            assert_eq!(p, path.to_path_buf());
547        } else {
548            panic!("Expected TemplateNotFound error");
549        }
550    }
551
552    #[test]
553    fn test_copy_template_valid_folder_template() {
554        let temp_fs = tree_fs::TreeBuilder::default()
555            .drop(true)
556            .create()
557            .expect("Failed to create temporary file system");
558
559        let template_dir = template::tests::find_first_dir();
560
561        let copy_result = copy_template(template_dir.path(), temp_fs.root.as_path());
562        assert!(
563            copy_result.is_ok(),
564            "Failed to copy template from directory {:?}",
565            template_dir.path()
566        );
567
568        let template_files = template::collect_files_from_path(template_dir.path())
569            .expect("Failed to collect files from the template directory");
570
571        assert!(
572            !template_files.is_empty(),
573            "No files found in the template directory"
574        );
575
576        for template_file in template_files {
577            let copy_file_path = temp_fs.root.join(template_file.path());
578
579            assert!(
580                copy_file_path.exists(),
581                "Copy file does not exist: {copy_file_path:?}"
582            );
583
584            let copy_content =
585                fs::read_to_string(&copy_file_path).expect("Failed to read coped file content");
586
587            assert_eq!(
588                template_file
589                    .contents_utf8()
590                    .expect("Failed to get template file content"),
591                copy_content,
592                "Content mismatch in file: {copy_file_path:?}"
593            );
594        }
595    }
596
597    fn test_mapping() -> Mappings {
598        Mappings {
599            field_types: vec![
600                FieldType {
601                    name: "array".to_string(),
602                    rust: RustType::Map(HashMap::from([
603                        ("string".to_string(), "Vec<String>".to_string()),
604                        ("chat".to_string(), "Vec<String>".to_string()),
605                        ("int".to_string(), "Vec<i32>".to_string()),
606                    ])),
607                    schema: "array".to_string(),
608                    col_type: "array_null".to_string(),
609                    arity: 1,
610                },
611                FieldType {
612                    name: "string^".to_string(),
613                    rust: RustType::String("String".to_string()),
614                    schema: "string_uniq".to_string(),
615                    col_type: "StringUniq".to_string(),
616                    arity: 0,
617                },
618            ],
619        }
620    }
621
622    #[test]
623    fn can_get_all_names_from_mapping() {
624        let mapping = test_mapping();
625        assert_eq!(
626            mapping.all_names(),
627            Vec::from([&"array".to_string(), &"string^".to_string()])
628        );
629    }
630
631    #[test]
632    fn can_get_col_type_arity_from_mapping() {
633        let mapping = test_mapping();
634
635        assert_eq!(mapping.col_type_arity("array").expect("Get array arity"), 1);
636        assert_eq!(
637            mapping
638                .col_type_arity("string^")
639                .expect("Get string^ arity"),
640            0
641        );
642
643        assert!(mapping.col_type_arity("unknown").is_err());
644    }
645
646    #[test]
647    fn can_get_col_type_field_from_mapping() {
648        let mapping = test_mapping();
649
650        assert_eq!(
651            mapping.col_type_field("array").expect("Get array field"),
652            "array_null"
653        );
654
655        assert!(mapping.col_type_field("unknown").is_err());
656    }
657
658    #[test]
659    fn can_get_schema_field_from_mapping() {
660        let mapping = test_mapping();
661
662        assert_eq!(
663            mapping.schema_field("string^").expect("Get string^ schema"),
664            "string_uniq"
665        );
666
667        assert!(mapping.schema_field("unknown").is_err());
668    }
669
670    #[test]
671    fn can_get_rust_field_from_mapping() {
672        let mapping = test_mapping();
673
674        assert_eq!(
675            mapping
676                .rust_field("string^")
677                .expect("Get string^ rust field"),
678            "String"
679        );
680
681        assert!(mapping.rust_field("array").is_err());
682
683        assert!(mapping.rust_field("unknown").is_err(),);
684    }
685
686    #[test]
687    fn can_get_rust_field_kind_from_mapping() {
688        let mapping = test_mapping();
689
690        assert!(mapping.rust_field_kind("string^").is_ok());
691
692        assert!(mapping.rust_field_kind("unknown").is_err(),);
693    }
694
695    #[test]
696    fn can_get_rust_field_with_params_from_mapping() {
697        let mapping = test_mapping();
698
699        assert_eq!(
700            mapping
701                .rust_field_with_params("string^", &vec!["string".to_string()])
702                .expect("Get string^ rust field"),
703            "String"
704        );
705
706        assert_eq!(
707            mapping
708                .rust_field_with_params("array", &vec!["string".to_string()])
709                .expect("Get string^ rust field"),
710            "Vec<String>"
711        );
712        assert!(mapping
713            .rust_field_with_params("array", &vec!["unknown".to_string()])
714            .is_err());
715
716        assert!(mapping.rust_field_with_params("unknown", &vec![]).is_err());
717    }
718
719    #[test]
720    fn can_collect_messages() {
721        let gen_result = GenerateResults {
722            rrgen: vec![
723                GenResult::Skipped,
724                GenResult::Generated {
725                    message: Some("test".to_string()),
726                },
727                GenResult::Generated {
728                    message: Some("test2".to_string()),
729                },
730                GenResult::Generated { message: None },
731            ],
732            local_templates: vec![
733                PathBuf::from("template").join("scheduler.t"),
734                PathBuf::from("template").join("task.t"),
735            ],
736        };
737
738        let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
739
740        assert_eq!(
741            re.replace_all(&collect_messages(&gen_result), ""),
742            r"* test
743* test2
744
745The following templates were sourced from the local templates:
746* template/scheduler.t
747* template/task.t
748"
749        );
750    }
751}