1use crate::components::{ComponentDef, Library};
9
10#[derive(Default)]
14pub struct PromptOptions {
15 pub preamble: Option<String>,
17 pub additional_rules: Vec<String>,
19 pub examples: Vec<String>,
21}
22
23const PREAMBLE: &str = "You are an AI assistant that can generate rich interactive UI using openui-lang, a declarative UI language. When the user asks for visual content (dashboards, charts, tables, forms, lists, etc.), respond with openui-lang code wrapped in a ```openui fenced code block. You may include explanatory markdown text before and/or after the code block.";
29
30fn syntax_rules(root_name: &str) -> String {
34 format!(
35 r#"## Syntax Rules
361. Each statement is on its own line: `identifier = Expression`
372. `root` is the entry point — every program must define `root = {root_name}(...)`
383. Expressions are: strings ("..."), numbers, booleans (true/false), arrays ([...]), objects ({{...}}), or component calls TypeName(arg1, arg2, ...)
394. Use references for readability: define `name = ...` on one line, then use `name` later
405. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render.
416. Arguments are POSITIONAL (order matters, not names)
427. Optional arguments can be omitted from the end
438. No operators, no logic, no variables — only declarations
449. Strings use double quotes with backslash escaping"#
45 )
46}
47
48fn streaming_rules(root_name: &str) -> String {
52 format!(
53 r#"## Hoisting & Streaming (CRITICAL)
54openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed.
55During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in.
56**Recommended statement order for optimal streaming:**
571. `root = {root_name}(...)` — UI shell appears immediately
582. Component definitions — fill in as they stream
593. Data values — leaf content last
60Always write the root = {root_name}(...) statement first so the UI shell appears immediately."#
61 )
62}
63
64fn important_rules(root_name: &str) -> String {
68 format!(
69 r#"## Important Rules
70- ALWAYS start with root = {root_name}(...)
71- Write statements in TOP-DOWN order: root → components → data (leverages hoisting for progressive streaming)
72- Each statement on its own line
73- When asked about data, generate realistic/plausible data
74- Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.)
75- NEVER define a variable without referencing it from the tree. Every variable must be reachable from root.
76- Wrap your openui-lang code in a ```openui fenced code block
77- You may include explanatory text before or after the code block"#
78 )
79}
80
81fn build_component_line(component: &ComponentDef) -> String {
87 let params: Vec<String> = component
88 .props
89 .iter()
90 .map(|p| {
91 if p.optional {
92 format!("{}?: {}", p.name, p.type_annotation)
93 } else {
94 format!("{}: {}", p.name, p.type_annotation)
95 }
96 })
97 .collect();
98
99 let sig = format!("{}({})", component.name, params.join(", "));
100
101 if component.description.is_empty() {
102 sig
103 } else {
104 format!("{} — {}", sig, component.description)
105 }
106}
107
108fn generate_component_signatures(library: &Library) -> String {
112 let mut lines = vec![
113 "## Component Signatures".to_string(),
114 String::new(),
115 "Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming.".to_string(),
116 ];
117
118 if library.groups.is_empty() {
119 lines.push(String::new());
121 for comp in &library.components {
122 lines.push(build_component_line(comp));
123 }
124 } else {
125 let comp_map: std::collections::HashMap<&str, &ComponentDef> =
127 library.components.iter().map(|c| (c.name, c)).collect();
128
129 let mut grouped_names = std::collections::HashSet::new();
130
131 for group in &library.groups {
132 lines.push(String::new());
133 lines.push(format!("### {}", group.name));
134
135 for name in &group.components {
136 if grouped_names.contains(name) {
137 continue;
138 }
139 if let Some(comp) = comp_map.get(name) {
140 grouped_names.insert(*name);
141 lines.push(build_component_line(comp));
142 }
143 }
144
145 for note in &group.notes {
146 lines.push(note.to_string());
147 }
148 }
149
150 let ungrouped: Vec<&ComponentDef> = library
152 .components
153 .iter()
154 .filter(|c| !grouped_names.contains(c.name))
155 .collect();
156
157 if !ungrouped.is_empty() {
158 lines.push(String::new());
159 lines.push("### Ungrouped".to_string());
160 for comp in ungrouped {
161 lines.push(build_component_line(comp));
162 }
163 }
164 }
165
166 lines.join("\n")
167}
168
169#[allow(clippy::vec_init_then_push)]
176pub fn generate_prompt(library: &Library, options: &PromptOptions) -> String {
177 let root_name = library.root;
178 let mut parts = Vec::new();
179
180 parts.push(options.preamble.as_deref().unwrap_or(PREAMBLE).to_string());
182 parts.push(String::new());
183
184 parts.push(syntax_rules(root_name));
186 parts.push(String::new());
187
188 parts.push(generate_component_signatures(library));
190 parts.push(String::new());
191
192 parts.push(streaming_rules(root_name));
194
195 if !options.examples.is_empty() {
197 parts.push(String::new());
198 parts.push("## Examples".to_string());
199 parts.push(String::new());
200 for ex in &options.examples {
201 parts.push(ex.clone());
202 parts.push(String::new());
203 }
204 }
205
206 parts.push(important_rules(root_name));
208
209 if !options.additional_rules.is_empty() {
211 parts.push(String::new());
212 for rule in &options.additional_rules {
213 parts.push(format!("- {rule}"));
214 }
215 }
216
217 parts.join("\n")
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::components::PropDef;
224
225 fn test_library() -> Library {
226 Library {
227 root: "Stack",
228 components: vec![
229 ComponentDef {
230 name: "Stack",
231 props: &[PropDef {
232 name: "children",
233 type_annotation: "any[]",
234 optional: false,
235 }],
236 description: "Flex container",
237 },
238 ComponentDef {
239 name: "TextContent",
240 props: &[
241 PropDef {
242 name: "text",
243 type_annotation: "string",
244 optional: false,
245 },
246 PropDef {
247 name: "size",
248 type_annotation: "\"small\" | \"large\"",
249 optional: true,
250 },
251 ],
252 description: "Text block",
253 },
254 ],
255 groups: vec![],
256 }
257 }
258
259 #[test]
260 fn test_generate_prompt_contains_preamble() {
261 let lib = test_library();
262 let prompt = generate_prompt(&lib, &PromptOptions::default());
263 assert!(prompt.contains("openui-lang"));
264 assert!(prompt.contains("```openui"));
265 }
266
267 #[test]
268 fn test_generate_prompt_contains_syntax_rules() {
269 let lib = test_library();
270 let prompt = generate_prompt(&lib, &PromptOptions::default());
271 assert!(prompt.contains("## Syntax Rules"));
272 assert!(prompt.contains("root = Stack(...)"));
273 assert!(prompt.contains("POSITIONAL"));
274 }
275
276 #[test]
277 fn test_generate_prompt_contains_component_signatures() {
278 let lib = test_library();
279 let prompt = generate_prompt(&lib, &PromptOptions::default());
280 assert!(prompt.contains("## Component Signatures"));
281 assert!(prompt.contains("Stack(children: any[]) — Flex container"));
282 assert!(
283 prompt.contains("TextContent(text: string, size?: \"small\" | \"large\") — Text block")
284 );
285 }
286
287 #[test]
288 fn test_generate_prompt_contains_streaming_rules() {
289 let lib = test_library();
290 let prompt = generate_prompt(&lib, &PromptOptions::default());
291 assert!(prompt.contains("## Hoisting & Streaming"));
292 assert!(prompt.contains("hoisting"));
293 }
294
295 #[test]
296 fn test_generate_prompt_contains_important_rules() {
297 let lib = test_library();
298 let prompt = generate_prompt(&lib, &PromptOptions::default());
299 assert!(prompt.contains("## Important Rules"));
300 assert!(prompt.contains("ALWAYS start with root = Stack(...)"));
301 }
302
303 #[test]
304 fn test_custom_preamble() {
305 let lib = test_library();
306 let options = PromptOptions {
307 preamble: Some("Custom preamble here".to_string()),
308 ..Default::default()
309 };
310 let prompt = generate_prompt(&lib, &options);
311 assert!(prompt.contains("Custom preamble here"));
312 assert!(!prompt.contains("You are an AI assistant"));
313 }
314
315 #[test]
316 fn test_additional_rules() {
317 let lib = test_library();
318 let options = PromptOptions {
319 additional_rules: vec!["Always use dark theme".to_string()],
320 ..Default::default()
321 };
322 let prompt = generate_prompt(&lib, &options);
323 assert!(prompt.contains("- Always use dark theme"));
324 }
325
326 #[test]
327 fn test_examples() {
328 let lib = test_library();
329 let options = PromptOptions {
330 examples: vec!["root = Stack([text])\ntext = TextContent(\"Hello\")".to_string()],
331 ..Default::default()
332 };
333 let prompt = generate_prompt(&lib, &options);
334 assert!(prompt.contains("## Examples"));
335 assert!(prompt.contains("root = Stack([text])"));
336 }
337
338 #[test]
339 fn test_grouped_components() {
340 use crate::groups::ComponentGroup;
341
342 let lib = Library {
343 root: "Stack",
344 components: vec![
345 ComponentDef {
346 name: "Stack",
347 props: &[PropDef {
348 name: "children",
349 type_annotation: "any[]",
350 optional: false,
351 }],
352 description: "Flex container",
353 },
354 ComponentDef {
355 name: "TextContent",
356 props: &[PropDef {
357 name: "text",
358 type_annotation: "string",
359 optional: false,
360 }],
361 description: "Text block",
362 },
363 ],
364 groups: vec![
365 ComponentGroup {
366 name: "Layout",
367 components: vec!["Stack"],
368 notes: vec![],
369 },
370 ComponentGroup {
371 name: "Content",
372 components: vec!["TextContent"],
373 notes: vec!["Use TextContent for plain text."],
374 },
375 ],
376 };
377
378 let prompt = generate_prompt(&lib, &PromptOptions::default());
379 assert!(prompt.contains("### Layout"));
380 assert!(prompt.contains("### Content"));
381 assert!(prompt.contains("Use TextContent for plain text."));
382 }
383
384 #[test]
385 fn test_build_component_line_required_only() {
386 let comp = ComponentDef {
387 name: "Table",
388 props: &[PropDef {
389 name: "data",
390 type_annotation: "any[]",
391 optional: false,
392 }],
393 description: "Data table",
394 };
395 let line = build_component_line(&comp);
396 assert_eq!(line, "Table(data: any[]) — Data table");
397 }
398
399 #[test]
400 fn test_build_component_line_mixed_props() {
401 let comp = ComponentDef {
402 name: "Button",
403 props: &[
404 PropDef {
405 name: "label",
406 type_annotation: "string",
407 optional: false,
408 },
409 PropDef {
410 name: "variant",
411 type_annotation: "\"primary\" | \"secondary\"",
412 optional: true,
413 },
414 ],
415 description: "Clickable button",
416 };
417 let line = build_component_line(&comp);
418 assert_eq!(
419 line,
420 "Button(label: string, variant?: \"primary\" | \"secondary\") — Clickable button"
421 );
422 }
423
424 #[test]
425 fn test_build_component_line_no_props() {
426 let comp = ComponentDef {
427 name: "Separator",
428 props: &[],
429 description: "Visual divider",
430 };
431 let line = build_component_line(&comp);
432 assert_eq!(line, "Separator() — Visual divider");
433 }
434}