1use console::style;
11use ferro_json_ui::{global_catalog, Spec};
12use regex::Regex;
13use std::fs;
14use std::path::Path;
15
16use crate::commands::generate_routes;
17
18pub fn call_anthropic_plain(system: &str, user_prompt: &str) -> Result<String, String> {
28 call_anthropic(system, user_prompt)
29}
30
31pub fn call_anthropic(system: &str, user_prompt: &str) -> Result<String, String> {
32 let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
33 "ANTHROPIC_API_KEY not set. Export it with:\n \
34 export ANTHROPIC_API_KEY=sk-ant-...\n\
35 Or use --no-ai for a static template."
36 .to_string()
37 })?;
38
39 let model = std::env::var("FERRO_AI_MODEL").unwrap_or_else(|_| "claude-sonnet-4-5".to_string());
40
41 let body = serde_json::json!({
42 "model": model,
43 "max_tokens": 1024,
44 "temperature": 0.2,
45 "system": [
46 {
47 "type": "text",
48 "text": system,
49 "cache_control": {"type": "ephemeral"}
50 }
51 ],
52 "messages": [
53 {"role": "user", "content": user_prompt}
54 ]
55 });
56
57 let client = reqwest::blocking::Client::builder()
58 .timeout(std::time::Duration::from_secs(60))
59 .build()
60 .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
61
62 let response = client
63 .post("https://api.anthropic.com/v1/messages")
64 .header("x-api-key", &api_key)
65 .header("anthropic-version", "2023-06-01")
66 .header("content-type", "application/json")
67 .json(&body)
68 .send()
69 .map_err(|e| format!("API request failed: {e}"))?;
70
71 let status = response.status();
72 let text = response
73 .text()
74 .map_err(|e| format!("Failed to read response body: {e}"))?;
75
76 if !status.is_success() {
77 return Err(format!("Anthropic API error ({status}): {text}"));
78 }
79
80 let json: serde_json::Value =
81 serde_json::from_str(&text).map_err(|e| format!("Failed to parse response JSON: {e}"))?;
82
83 let response_text = json["content"]
84 .as_array()
85 .and_then(|arr| arr.first())
86 .and_then(|item| item["text"].as_str())
87 .ok_or_else(|| format!("Unexpected response structure: {text}"))?;
88
89 Ok(response_text.to_string())
90}
91
92pub fn call_anthropic_structured(
100 system: &str,
101 user_prompt: &str,
102 schema: serde_json::Value,
103) -> Result<String, String> {
104 let api_key =
105 std::env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY not set.".to_string())?;
106
107 let model = std::env::var("FERRO_AI_MODEL").unwrap_or_else(|_| "claude-sonnet-4-5".to_string());
108
109 let body = serde_json::json!({
110 "model": model,
111 "max_tokens": 4096,
112 "temperature": 0.2,
113 "system": [
114 {
115 "type": "text",
116 "text": system,
117 "cache_control": {"type": "ephemeral"}
118 }
119 ],
120 "tools": [
121 {
122 "name": "emit_spec",
123 "description": "Emit the complete JSON-UI v2 spec for the requested view.",
124 "input_schema": schema
125 }
126 ],
127 "tool_choice": { "type": "tool", "name": "emit_spec" },
128 "messages": [
129 {"role": "user", "content": user_prompt}
130 ]
131 });
132
133 let client = reqwest::blocking::Client::builder()
134 .timeout(std::time::Duration::from_secs(90))
135 .build()
136 .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
137
138 let response = client
139 .post("https://api.anthropic.com/v1/messages")
140 .header("x-api-key", &api_key)
141 .header("anthropic-version", "2023-06-01")
142 .header("content-type", "application/json")
143 .json(&body)
144 .send()
145 .map_err(|e| format!("API request failed: {e}"))?;
146
147 let status = response.status();
148 let text = response
149 .text()
150 .map_err(|e| format!("Failed to read response body: {e}"))?;
151
152 if !status.is_success() {
153 return Err(format!("Anthropic API error ({status}): {text}"));
154 }
155
156 let json: serde_json::Value =
157 serde_json::from_str(&text).map_err(|e| format!("Failed to parse response JSON: {e}"))?;
158
159 let tool_input = json["content"]
161 .as_array()
162 .and_then(|arr| arr.iter().find(|item| item["type"] == "tool_use"))
163 .and_then(|item| item.get("input"))
164 .cloned()
165 .ok_or_else(|| format!("No tool_use block in response: {text}"))?;
166
167 serde_json::to_string_pretty(&tool_input)
168 .map_err(|e| format!("Failed to serialize tool input: {e}"))
169}
170
171pub fn build_json_view_pass1(name: &str, description: &str) -> (String, String) {
175 let catalog = global_catalog();
176 let catalog_prompt = catalog.prompt();
177
178 let system = format!(
179 "You are a JSON-UI v2 view planner for the Ferro framework.\n\n\
180 {catalog_prompt}\n\n\
181 Given a view name and description, produce a concise plain-text component plan: \
182 which components to use, what data each displays, what actions are present. \
183 Do not emit any JSON or code — only a human-readable plan."
184 );
185
186 let user = format!(
187 "View name: {name}\n\
188 Description: {description}\n\n\
189 Describe the component plan for this view."
190 );
191
192 (system, user)
193}
194
195pub fn build_json_view_pass2(pass1_result: &str) -> (String, String) {
200 let system = format!(
201 "You are a JSON-UI v2 spec generator for the Ferro framework.\n\n\
202 Component plan from previous step:\n{pass1_result}\n\n\
203 Generate the complete v2 JSON spec matching this plan. \
204 Root element id must be \"root\". \
205 All element ids are unique strings. Use flat elements map — no nesting."
206 );
207
208 let user =
209 "Generate the complete JSON-UI v2 spec for the view described in the component plan."
210 .to_string();
211
212 (system, user)
213}
214
215pub fn generate_json_view(name: &str, description: &str, layout: &str) -> Result<String, String> {
223 let catalog = global_catalog();
224 let catalog_prompt = catalog.prompt();
225
226 let models = scan_models();
228 let routes = scan_routes();
229
230 let mut project_context = String::new();
231 if !models.is_empty() {
232 project_context.push_str("## Project Models\n");
233 project_context.push_str(&models);
234 project_context.push('\n');
235 }
236 if !routes.is_empty() {
237 project_context.push_str("## Project Routes\n");
238 project_context.push_str(&routes);
239 project_context.push('\n');
240 }
241
242 let pass1_system = format!(
244 "You are a JSON-UI v2 view planner for the Ferro framework.\n\n\
245 {catalog_prompt}\n\n\
246 Given a view name, description, and project context, produce a concise plain-text \
247 component plan: which components to use, what data each displays, what actions are \
248 present. Do not emit any JSON or code — only a human-readable plan."
249 );
250
251 let pass1_user = format!(
252 "{project_context}\
253 View name: {name}\n\
254 Layout: {layout}\n\
255 Description: {description}\n\n\
256 Describe the component plan for this view."
257 );
258
259 let pass1_result = call_anthropic_plain(&pass1_system, &pass1_user)?;
260
261 let schema = catalog.json_schema().clone();
263
264 let pass2_system = format!(
265 "You are a JSON-UI v2 spec generator for the Ferro framework.\n\n\
266 Component plan from previous step:\n{pass1_result}\n\n\
267 Generate the complete v2 JSON spec matching this plan. \
268 Use layout \"{layout}\". Root element id must be \"root\". \
269 All element ids are unique strings. Use flat elements map — no nesting."
270 );
271
272 let pass2_user = format!("Generate the complete JSON-UI v2 spec for the \"{name}\" view.");
273
274 let spec_json = call_anthropic_structured(&pass2_system, &pass2_user, schema)?;
275
276 match Spec::from_json(&spec_json) {
278 Ok(spec) => {
279 if let Err(errors) = catalog.validate(&spec) {
280 let msgs: Vec<String> = errors.iter().map(|e| format!(" - {e}")).collect();
281 eprintln!(
282 "{} Generated spec has validation errors:\n{}",
283 style("Warning:").yellow().bold(),
284 msgs.join("\n")
285 );
286 return Err("Spec validation failed — using static template.".to_string());
287 }
288 }
289 Err(e) => {
290 eprintln!(
291 "{} Generated spec failed structural parse: {e}",
292 style("Warning:").yellow().bold(),
293 );
294 return Err("Spec parse failed — using static template.".to_string());
295 }
296 }
297
298 Ok(spec_json)
299}
300
301fn scan_models() -> String {
303 let models_dir = Path::new("src/models");
304 if !models_dir.exists() {
305 return String::new();
306 }
307
308 let struct_re = Regex::new(r"pub\s+struct\s+(\w+)\s*\{").unwrap();
309 let field_re = Regex::new(r"pub\s+(\w+)\s*:\s*([^,\n]+)").unwrap();
310
311 let mut output = String::new();
312
313 let entries: Vec<_> = match fs::read_dir(models_dir) {
314 Ok(entries) => entries.filter_map(|e| e.ok()).collect(),
315 Err(_) => return String::new(),
316 };
317
318 for entry in entries {
319 let path = entry.path();
320 if path.extension().is_none_or(|ext| ext != "rs") {
321 continue;
322 }
323 if path.file_name().is_some_and(|n| n == "mod.rs") {
324 continue;
325 }
326
327 let content = match fs::read_to_string(&path) {
328 Ok(c) => c,
329 Err(_) => continue,
330 };
331
332 for struct_cap in struct_re.captures_iter(&content) {
334 let struct_name = &struct_cap[1];
335 let struct_start = struct_cap.get(0).unwrap().end();
336
337 let rest = &content[struct_start..];
339 let mut depth = 1;
340 let mut struct_end = rest.len();
341 for (byte_idx, ch) in rest.char_indices() {
342 match ch {
343 '{' => depth += 1,
344 '}' => {
345 depth -= 1;
346 if depth == 0 {
347 struct_end = byte_idx;
348 break;
349 }
350 }
351 _ => {}
352 }
353 }
354
355 let struct_body = &rest[..struct_end];
356 let fields: Vec<String> = field_re
357 .captures_iter(struct_body)
358 .map(|cap| {
359 let field_name = cap[1].trim();
360 let field_type = cap[2].trim().trim_end_matches(',');
361 format!("{field_name} ({field_type})")
362 })
363 .collect();
364
365 if !fields.is_empty() {
366 output.push_str(&format!("### {}: {}\n", struct_name, fields.join(", ")));
367 }
368 }
369 }
370
371 output
372}
373
374fn scan_routes() -> String {
376 let routes_file = Path::new("src/routes.rs");
377 if !routes_file.exists() {
378 return String::new();
379 }
380
381 let content = match fs::read_to_string(routes_file) {
382 Ok(c) => c,
383 Err(_) => return String::new(),
384 };
385
386 let routes = generate_routes::parse_routes_file(&content);
387 let mut output = String::new();
388
389 for route in &routes {
390 let method = match route.method {
391 generate_routes::HttpMethod::Get => "GET",
392 generate_routes::HttpMethod::Post => "POST",
393 generate_routes::HttpMethod::Put => "PUT",
394 generate_routes::HttpMethod::Patch => "PATCH",
395 generate_routes::HttpMethod::Delete => "DELETE",
396 };
397
398 let name_suffix = route
399 .name
400 .as_ref()
401 .map(|n| format!(" (name: \"{n}\")"))
402 .unwrap_or_default();
403
404 output.push_str(&format!(
405 "{} {} -> {}::{}{}\n",
406 method, route.path, route.handler_module, route.handler_fn, name_suffix
407 ));
408 }
409
410 output
411}