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.set_trim_blocks(true);
97    env.add_template(
98        "package.json.j2",
99        include_str!("../../templates/package.json.j2"),
100    )
101    .expect("template should be valid");
102    let tmpl = env.get_template("package.json.j2").unwrap();
103
104    let pkg_name = options
105        .package_name
106        .clone()
107        .unwrap_or_else(|| slugify(&options.name));
108
109    let biome = options.formatter.as_deref() == Some("biome");
110    let vitest = options.test_runner.as_deref() == Some("vitest");
111    let tsdown = options.bundler.as_deref() == Some("tsdown");
112
113    tmpl.render(context! {
114        name => pkg_name,
115        repository => options.repository,
116        react => options.react,
117        biome => biome,
118        vitest => vitest,
119        tsdown => tsdown,
120    })
121    .expect("render should succeed")
122}
123
124fn emit_tsconfig(options: &ScaffoldOptions) -> String {
125    let mut env = Environment::new();
126    env.add_template(
127        "tsconfig.json.j2",
128        include_str!("../../templates/tsconfig.json.j2"),
129    )
130    .expect("template should be valid");
131    let tmpl = env.get_template("tsconfig.json.j2").unwrap();
132
133    tmpl.render(context! {
134        react => options.react,
135        source_dir => options.source_dir,
136    })
137    .expect("render should succeed")
138}
139
140fn emit_biome() -> String {
141    include_str!("../../templates/biome.json.j2").to_string()
142}
143
144fn emit_tsdown(react: bool, source_dir: &str) -> String {
145    let mut env = Environment::new();
146    env.add_template(
147        "tsdown.config.ts.j2",
148        include_str!("../../templates/tsdown.config.ts.j2"),
149    )
150    .expect("template should be valid");
151    let tmpl = env.get_template("tsdown.config.ts.j2").unwrap();
152
153    tmpl.render(context! {
154        react => react,
155        source_dir => source_dir,
156    })
157    .expect("render should succeed")
158}
159
160/// Convert a title to a kebab-case package name.
161fn slugify(title: &str) -> String {
162    let slug: String = title
163        .chars()
164        .map(|c| {
165            if c.is_alphanumeric() {
166                c.to_ascii_lowercase()
167            } else {
168                '-'
169            }
170        })
171        .collect();
172
173    // Collapse consecutive dashes and trim
174    let mut result = String::new();
175    let mut prev_dash = false;
176    for c in slug.chars() {
177        if c == '-' {
178            if !prev_dash && !result.is_empty() {
179                result.push('-');
180            }
181            prev_dash = true;
182        } else {
183            result.push(c);
184            prev_dash = false;
185        }
186    }
187
188    result.trim_end_matches('-').to_string()
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_slugify() {
197        assert_eq!(slugify("My API Service"), "my-api-service");
198        assert_eq!(slugify("SSE Chat API"), "sse-chat-api");
199        assert_eq!(slugify("Petstore - OpenAPI 3.2"), "petstore-openapi-3-2");
200    }
201
202    #[test]
203    fn test_emit_scaffold_with_all_options() {
204        let options = ScaffoldOptions {
205            name: "Test API".to_string(),
206            package_name: None,
207            repository: Some("https://github.com/test/repo".to_string()),
208            formatter: Some("biome".to_string()),
209            bundler: Some("tsdown".to_string()),
210            test_runner: Some("vitest".to_string()),
211            react: true,
212            existing_repo: false,
213            source_dir: "src".to_string(),
214        };
215        let files = emit_scaffold(&options);
216        assert_eq!(files.len(), 4);
217        assert!(files.iter().any(|f| f.path == "package.json"));
218        assert!(files.iter().any(|f| f.path == "tsconfig.json"));
219        assert!(files.iter().any(|f| f.path == "biome.json"));
220        assert!(files.iter().any(|f| f.path == "tsdown.config.ts"));
221    }
222
223    #[test]
224    fn test_emit_scaffold_minimal() {
225        let options = ScaffoldOptions {
226            name: "Test".to_string(),
227            package_name: None,
228            repository: None,
229            formatter: None,
230            bundler: None,
231            test_runner: None,
232            react: false,
233            existing_repo: false,
234            source_dir: "src".to_string(),
235        };
236        let files = emit_scaffold(&options);
237        assert_eq!(files.len(), 2); // Only package.json + tsconfig.json
238    }
239
240    #[test]
241    fn test_custom_package_name() {
242        let options = ScaffoldOptions {
243            name: "Some API".to_string(),
244            package_name: Some("@myorg/api-client".to_string()),
245            repository: None,
246            formatter: None,
247            bundler: None,
248            test_runner: None,
249            react: false,
250            existing_repo: false,
251            source_dir: "src".to_string(),
252        };
253        let files = emit_scaffold(&options);
254        let pkg = files.iter().find(|f| f.path == "package.json").unwrap();
255        assert!(pkg.content.contains("@myorg/api-client"));
256    }
257}