1use console::style;
8use ferro_ai::client::{Message, Role};
9use ferro_ai::{AiConfig, CompletionRequest};
10use ferro_json_ui::global_catalog;
11use std::fs;
12use std::path::Path;
13
14use crate::templates;
15
16pub fn run(name: String, description: Option<String>, no_ai: bool, layout: Option<String>) {
17 let file_name = to_snake_case(&name);
18
19 if !is_valid_identifier(&file_name) {
20 eprintln!(
21 "{} '{}' is not a valid view name",
22 style("Error:").red().bold(),
23 name
24 );
25 std::process::exit(1);
26 }
27
28 let views_dir = Path::new("src/views");
29 let view_file = views_dir.join(format!("{file_name}.json"));
30
31 if !views_dir.exists() {
33 if let Err(e) = fs::create_dir_all(views_dir) {
34 eprintln!(
35 "{} Failed to create src/views directory: {}",
36 style("Error:").red().bold(),
37 e
38 );
39 std::process::exit(1);
40 }
41 println!("{} Created src/views/", style("✓").green());
42 }
43
44 if view_file.exists() {
46 eprintln!(
47 "{} View '{}' already exists at {}",
48 style("Info:").yellow().bold(),
49 file_name,
50 view_file.display()
51 );
52 std::process::exit(0);
53 }
54
55 let layout_name = layout.as_deref().unwrap_or("dashboard");
56 let title = to_title_case(&file_name);
57
58 let content = if no_ai {
59 templates::json_view_template(&file_name, &title, layout_name)
60 } else {
61 match AiConfig::from_env() {
62 Ok(client) => {
63 let desc = description.as_deref().unwrap_or(&title);
64 println!(
65 "{} Generating view with AI (two passes)...",
66 style("⏳").cyan()
67 );
68 generate_with_ai(client.as_ref(), &file_name, &title, layout_name, desc)
69 }
70 Err(_) => {
71 if description.is_some() {
72 eprintln!(
73 "{} No AI provider configured. Set FERRO_AI_API_KEY (and optionally \
74 FERRO_AI_PROVIDER / FERRO_AI_MODEL), or use --no-ai to suppress this message.",
75 style("Info:").yellow().bold(),
76 );
77 }
78 templates::json_view_template(&file_name, &title, layout_name)
79 }
80 }
81 };
82
83 if let Err(e) = fs::write(&view_file, content) {
84 eprintln!(
85 "{} Failed to write view file: {}",
86 style("Error:").red().bold(),
87 e
88 );
89 std::process::exit(1);
90 }
91 println!("{} Created {}", style("✓").green(), view_file.display());
92
93 println!();
95 println!(
96 "View {} created successfully!",
97 style(&file_name).cyan().bold()
98 );
99 println!();
100 println!("Usage:");
101 println!(" {} Serve the view from a handler:", style("1.").dim());
102 println!();
103 println!(" #[handler]");
104 println!(" pub async fn {file_name}(req: Request) -> Response {{");
105 println!(" let data = serde_json::json!({{}});");
106 println!(" JsonUi::render_file(\"views/{file_name}.json\", data)");
107 println!(" }}");
108 println!();
109}
110
111fn generate_with_ai(
118 client: &dyn ferro_ai::LlmClient,
119 file_name: &str,
120 title: &str,
121 layout_name: &str,
122 description: &str,
123) -> String {
124 let rt = match tokio::runtime::Runtime::new() {
127 Ok(r) => r,
128 Err(e) => {
129 eprintln!(
130 "{} Failed to create tokio runtime: {}",
131 style("Warning:").yellow().bold(),
132 e
133 );
134 eprintln!("{}", style("Falling back to static template.").dim());
135 return templates::json_view_template(file_name, title, layout_name);
136 }
137 };
138
139 let (sys1, usr1) = build_json_view_pass1(file_name, description);
141 let req1 = CompletionRequest {
142 system: Some(sys1),
143 messages: vec![Message {
144 role: Role::User,
145 content: usr1,
146 tool_call_id: None,
147 }],
148 max_tokens: 1024,
149 model_override: None,
150 schema: None,
151 tools: None,
152 tool_choice: None,
153 };
154 let pass1_result = match rt.block_on(client.complete(req1)) {
155 Ok(text) => text,
156 Err(e) => {
157 eprintln!(
158 "{} AI Pass 1 failed: {}",
159 style("Warning:").yellow().bold(),
160 e
161 );
162 eprintln!("{}", style("Falling back to static template.").dim());
163 return templates::json_view_template(file_name, title, layout_name);
164 }
165 };
166
167 let (sys2, usr2) = build_json_view_pass2(&pass1_result);
169 let schema = ferro_json_ui::global_catalog().json_schema().clone();
170 let req2 = CompletionRequest {
171 system: Some(sys2),
172 messages: vec![Message {
173 role: Role::User,
174 content: usr2,
175 tool_call_id: None,
176 }],
177 max_tokens: 4096,
178 model_override: None,
179 schema: Some(schema),
181 tools: None,
182 tool_choice: None,
183 };
184 let json_str = match rt.block_on(client.complete(req2)) {
185 Ok(s) => s,
186 Err(e) => {
187 eprintln!(
188 "{} AI Pass 2 failed: {}",
189 style("Warning:").yellow().bold(),
190 e
191 );
192 eprintln!("{}", style("Falling back to static template.").dim());
193 return templates::json_view_template(file_name, title, layout_name);
194 }
195 };
196
197 match ferro_json_ui::Spec::from_json(&json_str) {
199 Err(parse_err) => {
200 eprintln!(
201 "{} Generated spec failed structural parse: {}",
202 style("Warning:").yellow().bold(),
203 parse_err
204 );
205 eprintln!("{}", style("Falling back to static template.").dim());
206 templates::json_view_template(file_name, title, layout_name)
207 }
208 Ok(spec) => match ferro_json_ui::global_catalog().validate(&spec) {
209 Ok(()) => json_str,
210 Err(errors) => {
211 eprintln!(
212 "{} Generated spec failed catalog validation ({} error{}):",
213 style("Warning:").yellow().bold(),
214 errors.len(),
215 if errors.len() == 1 { "" } else { "s" }
216 );
217 for err in &errors {
218 eprintln!(" - {err}");
219 }
220 eprintln!("{}", style("Falling back to static template.").dim());
221 templates::json_view_template(file_name, title, layout_name)
222 }
223 },
224 }
225}
226
227fn build_json_view_pass1(name: &str, description: &str) -> (String, String) {
231 let catalog = global_catalog();
232 let catalog_prompt = catalog.prompt();
233
234 let system = format!(
235 "You are a JSON-UI v2 view planner for the Ferro framework.\n\n\
236 {catalog_prompt}\n\n\
237 Given a view name and description, produce a concise plain-text component plan: \
238 which components to use, what data each displays, what actions are present. \
239 Do not emit any JSON or code — only a human-readable plan."
240 );
241
242 let user = format!(
243 "View name: {name}\n\
244 Description: {description}\n\n\
245 Describe the component plan for this view."
246 );
247
248 (system, user)
249}
250
251fn build_json_view_pass2(pass1_result: &str) -> (String, String) {
256 let system = format!(
257 "You are a JSON-UI v2 spec generator for the Ferro framework.\n\n\
258 Component plan from previous step:\n{pass1_result}\n\n\
259 Generate the complete v2 JSON spec matching this plan. \
260 Root element id must be \"root\". \
261 All element ids are unique strings. Use flat elements map — no nesting."
262 );
263
264 let user =
265 "Generate the complete JSON-UI v2 spec for the view described in the component plan."
266 .to_string();
267
268 (system, user)
269}
270
271fn is_valid_identifier(name: &str) -> bool {
272 if name.is_empty() {
273 return false;
274 }
275
276 let mut chars = name.chars();
277
278 match chars.next() {
279 Some(c) if c.is_alphabetic() || c == '_' => {}
280 _ => return false,
281 }
282
283 chars.all(|c| c.is_alphanumeric() || c == '_')
284}
285
286fn to_snake_case(s: &str) -> String {
287 let mut result = String::new();
288 for (i, c) in s.chars().enumerate() {
289 if c.is_uppercase() {
290 if i > 0 {
291 result.push('_');
292 }
293 result.push(c.to_lowercase().next().unwrap());
294 } else {
295 result.push(c);
296 }
297 }
298 result
299}
300
301fn to_title_case(s: &str) -> String {
302 s.split('_')
303 .map(|word| {
304 let mut chars = word.chars();
305 match chars.next() {
306 None => String::new(),
307 Some(first) => {
308 let mut result = first.to_uppercase().to_string();
309 result.extend(chars);
310 result
311 }
312 }
313 })
314 .collect::<Vec<_>>()
315 .join(" ")
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn to_snake_case_basic() {
324 assert_eq!(to_snake_case("UserList"), "user_list");
325 assert_eq!(to_snake_case("dashboard"), "dashboard");
326 }
327
328 #[test]
329 fn to_title_case_basic() {
330 assert_eq!(to_title_case("user_list"), "User List");
331 assert_eq!(to_title_case("dashboard"), "Dashboard");
332 }
333
334 #[test]
335 fn is_valid_identifier_accepts_snake_case() {
336 assert!(is_valid_identifier("user_list"));
337 assert!(is_valid_identifier("dashboard"));
338 }
339
340 #[test]
341 fn is_valid_identifier_rejects_invalid() {
342 assert!(!is_valid_identifier(""));
343 assert!(!is_valid_identifier("1bad"));
344 assert!(!is_valid_identifier("has-dash"));
345 }
346
347 #[test]
351 fn static_fallback_produces_valid_spec() {
352 let out = crate::templates::json_view_template("dashboard", "Dashboard", "dashboard");
353 let spec = ferro_json_ui::Spec::from_json(&out);
354 assert!(spec.is_ok(), "static fallback must parse: {spec:?}");
355 }
356}