Skip to main content

doido_generators/
project_generator.rs

1//! Runtime processing of project-local custom generators.
2//!
3//! A project generator is a folder of template files under
4//! `lib/generators/<name>/`. Running it substitutes the name argument into each
5//! file's relative path and contents, strips a trailing `.template`, and emits
6//! the result. No compilation required — the project owns its generators.
7
8use crate::generator::GeneratedFile;
9use crate::generators::{to_pascal, to_snake, to_table_name};
10use doido_core::{anyhow::anyhow, Result};
11use std::path::{Path, PathBuf};
12
13/// Root holding project-local custom generators.
14pub fn generators_root() -> PathBuf {
15    PathBuf::from("lib/generators")
16}
17
18/// The directory of the project generator named `name`, if it exists.
19pub fn find(name: &str) -> Option<PathBuf> {
20    find_in(&generators_root(), name)
21}
22
23/// Like [`find`] but rooted at an explicit directory (for tests).
24pub fn find_in(root: &Path, name: &str) -> Option<PathBuf> {
25    let dir = root.join(name);
26    dir.is_dir().then_some(dir)
27}
28
29/// Names of the project-local generators discovered under `lib/generators/`.
30pub fn list() -> Vec<String> {
31    list_in(&generators_root())
32}
33
34/// Like [`list`] but rooted at an explicit directory (for tests).
35pub fn list_in(root: &Path) -> Vec<String> {
36    let mut names: Vec<String> = match std::fs::read_dir(root) {
37        Ok(entries) => entries
38            .flatten()
39            .filter(|e| e.path().is_dir())
40            .filter_map(|e| e.file_name().into_string().ok())
41            .collect(),
42        Err(_) => Vec::new(),
43    };
44    names.sort();
45    names
46}
47
48/// Tokens derived from a single name argument, substituted into template paths
49/// and contents.
50struct Tokens {
51    name: String,
52    snake: String,
53    pascal: String,
54    plural: String,
55    controller: String,
56}
57
58impl Tokens {
59    fn from_name(name: &str) -> Self {
60        let pascal = to_pascal(name);
61        let plural = to_table_name(name);
62        Self {
63            name: name.to_string(),
64            snake: to_snake(name),
65            controller: format!("{}Controller", to_pascal(&plural)),
66            plural,
67            pascal,
68        }
69    }
70
71    fn apply(&self, s: &str) -> String {
72        s.replace("{name}", &self.name)
73            .replace("{snake}", &self.snake)
74            .replace("{singular}", &self.snake)
75            .replace("{pascal}", &self.pascal)
76            .replace("{Model}", &self.pascal)
77            .replace("{plural}", &self.plural)
78            .replace("{Controller}", &self.controller)
79    }
80}
81
82/// Process the generator at `dir` into output files. `args[0]` is the name the
83/// generated artifacts are built around.
84pub fn run(dir: &Path, args: &[&str]) -> Result<Vec<GeneratedFile>> {
85    let name = args
86        .first()
87        .copied()
88        .ok_or_else(|| anyhow!("this generator requires a name argument"))?;
89    let tokens = Tokens::from_name(name);
90    let mut files = Vec::new();
91    collect(dir, dir, &tokens, &mut files)?;
92    if files.is_empty() {
93        return Err(anyhow!(
94            "generator at {} has no template files",
95            dir.display()
96        ));
97    }
98    Ok(files)
99}
100
101/// Recursively gather template files under `dir`, skipping dotfiles and README.
102fn collect(base: &Path, dir: &Path, tokens: &Tokens, out: &mut Vec<GeneratedFile>) -> Result<()> {
103    let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
104        .map_err(|e| anyhow!("reading {}: {e}", dir.display()))?
105        .filter_map(|e| e.ok().map(|e| e.path()))
106        .collect();
107    entries.sort();
108
109    for path in entries {
110        let file_name = path
111            .file_name()
112            .map(|n| n.to_string_lossy().to_string())
113            .unwrap_or_default();
114        if file_name.starts_with('.') {
115            continue;
116        }
117        if path.is_dir() {
118            collect(base, &path, tokens, out)?;
119        } else {
120            if file_name.eq_ignore_ascii_case("README.md") {
121                continue;
122            }
123            let rel = path
124                .strip_prefix(base)
125                .unwrap_or(&path)
126                .to_string_lossy()
127                .replace('\\', "/");
128            let out_path = strip_template_ext(&tokens.apply(&rel));
129            let content = std::fs::read_to_string(&path)
130                .map_err(|e| anyhow!("reading {}: {e}", path.display()))?;
131            out.push(GeneratedFile {
132                path: out_path,
133                content: tokens.apply(&content),
134            });
135        }
136    }
137    Ok(())
138}
139
140fn strip_template_ext(p: &str) -> String {
141    p.strip_suffix(".template")
142        .map(str::to_string)
143        .unwrap_or_else(|| p.to_string())
144}