Skip to main content

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