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    pub existing_repo: Option<bool>,
17}
18
19/// Options controlling which scaffold files to generate.
20#[derive(Debug, Clone)]
21pub struct ScaffoldOptions {
22    /// Spec title, used as fallback for package name.
23    pub name: String,
24    /// Custom package name override (if None, derives from spec title).
25    pub package_name: Option<String>,
26    /// Repository URL for package.json.
27    pub repository: Option<String>,
28    /// Formatter tool name (e.g. "biome") or None if disabled.
29    pub formatter: Option<String>,
30    /// Test runner tool name (e.g. "vitest") or None if disabled.
31    pub test_runner: Option<String>,
32    /// Bundler tool name (e.g. "tsdown") or None if disabled.
33    pub bundler: Option<String>,
34    /// Whether React target is included.
35    pub react: bool,
36    /// Whether generating into an existing repo (skip all scaffold files).
37    pub existing_repo: bool,
38    /// Subdirectory for source files (e.g. "src", "lib", or "" for root).
39    pub source_dir: String,
40}
41
42/// Generate project scaffold files (package.json, tsconfig.json, biome.json, tsdown.config.ts).
43/// When `existing_repo` is true, only a root-level `index.ts` re-export is generated;
44/// all other scaffold files are skipped.
45pub fn emit_scaffold(options: &ScaffoldOptions) -> Vec<GeneratedFile> {
46    if options.existing_repo {
47        let ext = if options.react { "tsx" } else { "ts" };
48        let import_path = if options.source_dir.is_empty() {
49            "./index".to_string()
50        } else {
51            format!("./{}/index", options.source_dir)
52        };
53        return vec![GeneratedFile {
54            path: format!("index.{ext}"),
55            content: format!(
56                "// Auto-generated by oag — do not edit\nexport * from \"{import_path}\";\n"
57            ),
58        }];
59    }
60
61    let mut files = Vec::new();
62
63    // package.json
64    files.push(GeneratedFile {
65        path: "package.json".to_string(),
66        content: emit_package_json(options),
67    });
68
69    // tsconfig.json
70    files.push(GeneratedFile {
71        path: "tsconfig.json".to_string(),
72        content: emit_tsconfig(options),
73    });
74
75    // biome.json (optional)
76    if options.formatter.as_deref() == Some("biome") {
77        files.push(GeneratedFile {
78            path: "biome.json".to_string(),
79            content: emit_biome(),
80        });
81    }
82
83    // tsdown.config.ts (optional)
84    if options.bundler.as_deref() == Some("tsdown") {
85        files.push(GeneratedFile {
86            path: "tsdown.config.ts".to_string(),
87            content: emit_tsdown(options.react, &options.source_dir),
88        });
89    }
90
91    files
92}
93
94fn emit_package_json(options: &ScaffoldOptions) -> String {
95    let mut env = Environment::new();
96    env.add_template(
97        "package.json.j2",
98        include_str!("../../templates/package.json.j2"),
99    )
100    .expect("template should be valid");
101    let tmpl = env.get_template("package.json.j2").unwrap();
102
103    let pkg_name = options
104        .package_name
105        .clone()
106        .unwrap_or_else(|| slugify(&options.name));
107
108    let biome = options.formatter.as_deref() == Some("biome");
109    let vitest = options.test_runner.as_deref() == Some("vitest");
110    let tsdown = options.bundler.as_deref() == Some("tsdown");
111
112    tmpl.render(context! {
113        name => pkg_name,
114        repository => options.repository,
115        react => options.react,
116        biome => biome,
117        vitest => vitest,
118        tsdown => tsdown,
119    })
120    .expect("render should succeed")
121}
122
123fn emit_tsconfig(options: &ScaffoldOptions) -> String {
124    let mut env = Environment::new();
125    env.add_template(
126        "tsconfig.json.j2",
127        include_str!("../../templates/tsconfig.json.j2"),
128    )
129    .expect("template should be valid");
130    let tmpl = env.get_template("tsconfig.json.j2").unwrap();
131
132    tmpl.render(context! {
133        react => options.react,
134        source_dir => options.source_dir,
135    })
136    .expect("render should succeed")
137}
138
139fn emit_biome() -> String {
140    include_str!("../../templates/biome.json.j2").to_string()
141}
142
143fn emit_tsdown(react: bool, source_dir: &str) -> String {
144    let mut env = Environment::new();
145    env.add_template(
146        "tsdown.config.ts.j2",
147        include_str!("../../templates/tsdown.config.ts.j2"),
148    )
149    .expect("template should be valid");
150    let tmpl = env.get_template("tsdown.config.ts.j2").unwrap();
151
152    tmpl.render(context! {
153        react => react,
154        source_dir => source_dir,
155    })
156    .expect("render should succeed")
157}
158
159/// Convert a title to a kebab-case package name.
160fn slugify(title: &str) -> String {
161    let slug: String = title
162        .chars()
163        .map(|c| {
164            if c.is_alphanumeric() {
165                c.to_ascii_lowercase()
166            } else {
167                '-'
168            }
169        })
170        .collect();
171
172    // Collapse consecutive dashes and trim
173    let mut result = String::new();
174    let mut prev_dash = false;
175    for c in slug.chars() {
176        if c == '-' {
177            if !prev_dash && !result.is_empty() {
178                result.push('-');
179            }
180            prev_dash = true;
181        } else {
182            result.push(c);
183            prev_dash = false;
184        }
185    }
186
187    result.trim_end_matches('-').to_string()
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_slugify() {
196        assert_eq!(slugify("My API Service"), "my-api-service");
197        assert_eq!(slugify("SSE Chat API"), "sse-chat-api");
198        assert_eq!(slugify("Petstore - OpenAPI 3.2"), "petstore-openapi-3-2");
199    }
200
201    #[test]
202    fn test_emit_scaffold_with_all_options() {
203        let options = ScaffoldOptions {
204            name: "Test API".to_string(),
205            package_name: None,
206            repository: Some("https://github.com/test/repo".to_string()),
207            formatter: Some("biome".to_string()),
208            bundler: Some("tsdown".to_string()),
209            test_runner: Some("vitest".to_string()),
210            react: true,
211            existing_repo: false,
212            source_dir: "src".to_string(),
213        };
214        let files = emit_scaffold(&options);
215        assert_eq!(files.len(), 4);
216        assert!(files.iter().any(|f| f.path == "package.json"));
217        assert!(files.iter().any(|f| f.path == "tsconfig.json"));
218        assert!(files.iter().any(|f| f.path == "biome.json"));
219        assert!(files.iter().any(|f| f.path == "tsdown.config.ts"));
220    }
221
222    #[test]
223    fn test_emit_scaffold_minimal() {
224        let options = ScaffoldOptions {
225            name: "Test".to_string(),
226            package_name: None,
227            repository: None,
228            formatter: None,
229            bundler: None,
230            test_runner: None,
231            react: false,
232            existing_repo: false,
233            source_dir: "src".to_string(),
234        };
235        let files = emit_scaffold(&options);
236        assert_eq!(files.len(), 2); // Only package.json + tsconfig.json
237    }
238
239    #[test]
240    fn test_custom_package_name() {
241        let options = ScaffoldOptions {
242            name: "Some API".to_string(),
243            package_name: Some("@myorg/api-client".to_string()),
244            repository: None,
245            formatter: None,
246            bundler: None,
247            test_runner: None,
248            react: false,
249            existing_repo: false,
250            source_dir: "src".to_string(),
251        };
252        let files = emit_scaffold(&options);
253        let pkg = files.iter().find(|f| f.path == "package.json").unwrap();
254        assert!(pkg.content.contains("@myorg/api-client"));
255    }
256}