Skip to main content

oag_node_client/emitters/
scaffold.rs

1use minijinja::{Environment, context};
2use oag_core::GeneratedFile;
3use oag_core::config::ToolSetting;
4use serde::Deserialize;
5
6/// Node/TS-specific scaffold configuration, parsed from the opaque `serde_json::Value`.
7#[derive(Debug, Clone, Default, Deserialize)]
8#[serde(default)]
9pub struct NodeScaffoldConfig {
10    pub package_name: Option<String>,
11    pub repository: Option<String>,
12    pub index: Option<bool>,
13    pub formatter: Option<ToolSetting>,
14    pub test_runner: Option<ToolSetting>,
15    pub bundler: Option<ToolSetting>,
16}
17
18/// Options controlling which scaffold files to generate.
19#[derive(Debug, Clone)]
20pub struct ScaffoldOptions {
21    /// Spec title, used as fallback for package name.
22    pub name: String,
23    /// Custom package name override (if None, derives from spec title).
24    pub package_name: Option<String>,
25    /// Repository URL for package.json.
26    pub repository: Option<String>,
27    /// Formatter tool name (e.g. "biome") or None if disabled.
28    pub formatter: Option<String>,
29    /// Test runner tool name (e.g. "vitest") or None if disabled.
30    pub test_runner: Option<String>,
31    /// Bundler tool name (e.g. "tsdown") or None if disabled.
32    pub bundler: Option<String>,
33    /// Whether React target is included.
34    pub react: bool,
35}
36
37/// Generate project scaffold files (package.json, tsconfig.json, biome.json, tsdown.config.ts).
38pub fn emit_scaffold(options: &ScaffoldOptions) -> Vec<GeneratedFile> {
39    let mut files = Vec::new();
40
41    // package.json
42    files.push(GeneratedFile {
43        path: "package.json".to_string(),
44        content: emit_package_json(options),
45    });
46
47    // tsconfig.json
48    files.push(GeneratedFile {
49        path: "tsconfig.json".to_string(),
50        content: emit_tsconfig(options),
51    });
52
53    // biome.json (optional)
54    if options.formatter.as_deref() == Some("biome") {
55        files.push(GeneratedFile {
56            path: "biome.json".to_string(),
57            content: emit_biome(),
58        });
59    }
60
61    // tsdown.config.ts (optional)
62    if options.bundler.as_deref() == Some("tsdown") {
63        files.push(GeneratedFile {
64            path: "tsdown.config.ts".to_string(),
65            content: emit_tsdown(),
66        });
67    }
68
69    files
70}
71
72fn emit_package_json(options: &ScaffoldOptions) -> String {
73    let mut env = Environment::new();
74    env.add_template(
75        "package.json.j2",
76        include_str!("../../templates/package.json.j2"),
77    )
78    .expect("template should be valid");
79    let tmpl = env.get_template("package.json.j2").unwrap();
80
81    let pkg_name = options
82        .package_name
83        .clone()
84        .unwrap_or_else(|| slugify(&options.name));
85
86    let biome = options.formatter.as_deref() == Some("biome");
87    let vitest = options.test_runner.as_deref() == Some("vitest");
88    let tsdown = options.bundler.as_deref() == Some("tsdown");
89
90    tmpl.render(context! {
91        name => pkg_name,
92        repository => options.repository,
93        react => options.react,
94        biome => biome,
95        vitest => vitest,
96        tsdown => tsdown,
97    })
98    .expect("render should succeed")
99}
100
101fn emit_tsconfig(options: &ScaffoldOptions) -> String {
102    let mut env = Environment::new();
103    env.add_template(
104        "tsconfig.json.j2",
105        include_str!("../../templates/tsconfig.json.j2"),
106    )
107    .expect("template should be valid");
108    let tmpl = env.get_template("tsconfig.json.j2").unwrap();
109
110    tmpl.render(context! {
111        react => options.react,
112    })
113    .expect("render should succeed")
114}
115
116fn emit_biome() -> String {
117    include_str!("../../templates/biome.json.j2").to_string()
118}
119
120fn emit_tsdown() -> String {
121    include_str!("../../templates/tsdown.config.ts.j2").to_string()
122}
123
124/// Convert a title to a kebab-case package name.
125fn slugify(title: &str) -> String {
126    let slug: String = title
127        .chars()
128        .map(|c| {
129            if c.is_alphanumeric() {
130                c.to_ascii_lowercase()
131            } else {
132                '-'
133            }
134        })
135        .collect();
136
137    // Collapse consecutive dashes and trim
138    let mut result = String::new();
139    let mut prev_dash = false;
140    for c in slug.chars() {
141        if c == '-' {
142            if !prev_dash && !result.is_empty() {
143                result.push('-');
144            }
145            prev_dash = true;
146        } else {
147            result.push(c);
148            prev_dash = false;
149        }
150    }
151
152    result.trim_end_matches('-').to_string()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_slugify() {
161        assert_eq!(slugify("My API Service"), "my-api-service");
162        assert_eq!(slugify("SSE Chat API"), "sse-chat-api");
163        assert_eq!(slugify("Petstore - OpenAPI 3.2"), "petstore-openapi-3-2");
164    }
165
166    #[test]
167    fn test_emit_scaffold_with_all_options() {
168        let options = ScaffoldOptions {
169            name: "Test API".to_string(),
170            package_name: None,
171            repository: Some("https://github.com/test/repo".to_string()),
172            formatter: Some("biome".to_string()),
173            bundler: Some("tsdown".to_string()),
174            test_runner: Some("vitest".to_string()),
175            react: true,
176        };
177        let files = emit_scaffold(&options);
178        assert_eq!(files.len(), 4);
179        assert!(files.iter().any(|f| f.path == "package.json"));
180        assert!(files.iter().any(|f| f.path == "tsconfig.json"));
181        assert!(files.iter().any(|f| f.path == "biome.json"));
182        assert!(files.iter().any(|f| f.path == "tsdown.config.ts"));
183    }
184
185    #[test]
186    fn test_emit_scaffold_minimal() {
187        let options = ScaffoldOptions {
188            name: "Test".to_string(),
189            package_name: None,
190            repository: None,
191            formatter: None,
192            bundler: None,
193            test_runner: None,
194            react: false,
195        };
196        let files = emit_scaffold(&options);
197        assert_eq!(files.len(), 2); // Only package.json + tsconfig.json
198    }
199
200    #[test]
201    fn test_custom_package_name() {
202        let options = ScaffoldOptions {
203            name: "Some API".to_string(),
204            package_name: Some("@myorg/api-client".to_string()),
205            repository: None,
206            formatter: None,
207            bundler: None,
208            test_runner: None,
209            react: false,
210        };
211        let files = emit_scaffold(&options);
212        let pkg = files.iter().find(|f| f.path == "package.json").unwrap();
213        assert!(pkg.content.contains("@myorg/api-client"));
214    }
215}