1use ferro_json_ui::COMPONENT_CATALOG;
8use regex::Regex;
9use std::fs;
10use std::path::Path;
11
12use crate::commands::generate_routes;
13
14pub fn call_anthropic(system: &str, user_prompt: &str) -> Result<String, String> {
22 let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
23 "ANTHROPIC_API_KEY not set. Export it with:\n \
24 export ANTHROPIC_API_KEY=sk-ant-...\n\
25 Or use --no-ai for a static template."
26 .to_string()
27 })?;
28
29 let model = std::env::var("FERRO_AI_MODEL").unwrap_or_else(|_| "claude-sonnet-4-5".to_string());
30
31 let body = serde_json::json!({
32 "model": model,
33 "max_tokens": 8192,
34 "temperature": 0.2,
35 "system": [
36 {
37 "type": "text",
38 "text": system,
39 "cache_control": {"type": "ephemeral"}
40 }
41 ],
42 "messages": [
43 {"role": "user", "content": user_prompt},
44 {"role": "assistant", "content": "//!"}
45 ]
46 });
47
48 let client = reqwest::blocking::Client::builder()
49 .timeout(std::time::Duration::from_secs(60))
50 .build()
51 .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
52
53 let response = client
54 .post("https://api.anthropic.com/v1/messages")
55 .header("x-api-key", &api_key)
56 .header("anthropic-version", "2023-06-01")
57 .header("content-type", "application/json")
58 .json(&body)
59 .send()
60 .map_err(|e| format!("API request failed: {e}"))?;
61
62 let status = response.status();
63 let text = response
64 .text()
65 .map_err(|e| format!("Failed to read response body: {e}"))?;
66
67 if !status.is_success() {
68 return Err(format!("Anthropic API error ({status}): {text}"));
69 }
70
71 let json: serde_json::Value =
72 serde_json::from_str(&text).map_err(|e| format!("Failed to parse response JSON: {e}"))?;
73
74 let response_text = json["content"]
75 .as_array()
76 .and_then(|arr| arr.first())
77 .and_then(|item| item["text"].as_str())
78 .ok_or_else(|| format!("Unexpected response structure: {text}"))?;
79
80 Ok(format!("//!{response_text}"))
82}
83
84pub fn build_view_context(name: &str, description: &str) -> (String, String) {
90 let system = format!(
92 "You are a Ferro framework JSON-UI view code generator. Generate only valid Rust source \
93 code for src/views/ files.\n\n\
94 Rules:\n\
95 - Import only types actually used from `use ferro::{{...}};`\n\
96 - Use the builder pattern: `JsonUiView::new().title().layout().component()`\n\
97 - Use .layout(\"app\") unless the view is for auth (use \"auth\")\n\
98 - Use real route handler names for actions when matching routes exist\n\
99 - Use data_path bindings for form fields when matching model fields exist\n\
100 - Return ONLY Rust source code, no explanation\n\n\
101 {COMPONENT_CATALOG}\n\n\
102 <example>\n\
103 Input: user_list view showing all users in a table with edit and delete actions\n\
104 Output:\n\
105 //! User List JSON-UI view\n\n\
106 use ferro::{{\n\
107 Action, Component, ComponentNode, JsonUiView, TableColumn, TableProps, TextElement, \
108 TextProps,\n\
109 }};\n\n\
110 pub fn view() -> JsonUiView {{\n\
111 JsonUiView::new()\n\
112 .title(\"User List\")\n\
113 .layout(\"app\")\n\
114 .component(ComponentNode {{\n\
115 key: \"heading\".to_string(),\n\
116 component: Component::Text(TextProps {{\n\
117 content: \"User List\".to_string(),\n\
118 element: TextElement::H1,\n\
119 }}),\n\
120 action: None,\n\
121 visibility: None,\n\
122 }})\n\
123 .component(ComponentNode {{\n\
124 key: \"users_table\".to_string(),\n\
125 component: Component::Table(TableProps {{\n\
126 columns: vec![\n\
127 TableColumn {{ key: \"name\".to_string(), label: \"Name\".to_string(), \
128 format: None }},\n\
129 TableColumn {{ key: \"email\".to_string(), label: \
130 \"Email\".to_string(), format: None }},\n\
131 ],\n\
132 data_path: \"users\".to_string(),\n\
133 row_actions: Some(vec![\n\
134 Action::get(\"user_controller.edit\"),\n\
135 Action::delete(\"user_controller.destroy\").confirm_danger(\"Delete \
136 user\"),\n\
137 ]),\n\
138 empty_message: Some(\"No users found.\".to_string()),\n\
139 sortable: None,\n\
140 sort_column: None,\n\
141 sort_direction: None,\n\
142 }}),\n\
143 action: None,\n\
144 visibility: None,\n\
145 }})\n\
146 }}\n\
147 </example>",
148 );
149
150 let mut user_prompt = String::new();
152
153 let models = scan_models();
154 if !models.is_empty() {
155 user_prompt.push_str("## Project Models\n");
156 user_prompt.push_str(&models);
157 user_prompt.push('\n');
158 }
159
160 let routes = scan_routes();
161 if !routes.is_empty() {
162 user_prompt.push_str("## Project Routes\n");
163 user_prompt.push_str(&routes);
164 user_prompt.push('\n');
165 }
166
167 user_prompt.push_str(&format!(
168 "Generate `src/views/{name}.rs`:\n\
169 View name: {name}\n\
170 Description: {description}",
171 ));
172
173 (system, user_prompt)
174}
175
176fn scan_models() -> String {
178 let models_dir = Path::new("src/models");
179 if !models_dir.exists() {
180 return String::new();
181 }
182
183 let struct_re = Regex::new(r"pub\s+struct\s+(\w+)\s*\{").unwrap();
184 let field_re = Regex::new(r"pub\s+(\w+)\s*:\s*([^,\n]+)").unwrap();
185
186 let mut output = String::new();
187
188 let entries: Vec<_> = match fs::read_dir(models_dir) {
189 Ok(entries) => entries.filter_map(|e| e.ok()).collect(),
190 Err(_) => return String::new(),
191 };
192
193 for entry in entries {
194 let path = entry.path();
195 if path.extension().is_none_or(|ext| ext != "rs") {
196 continue;
197 }
198 if path.file_name().is_some_and(|n| n == "mod.rs") {
199 continue;
200 }
201
202 let content = match fs::read_to_string(&path) {
203 Ok(c) => c,
204 Err(_) => continue,
205 };
206
207 for struct_cap in struct_re.captures_iter(&content) {
209 let struct_name = &struct_cap[1];
210 let struct_start = struct_cap.get(0).unwrap().end();
211
212 let rest = &content[struct_start..];
214 let mut depth = 1;
215 let mut struct_end = rest.len();
216 for (i, ch) in rest.chars().enumerate() {
217 match ch {
218 '{' => depth += 1,
219 '}' => {
220 depth -= 1;
221 if depth == 0 {
222 struct_end = i;
223 break;
224 }
225 }
226 _ => {}
227 }
228 }
229
230 let struct_body = &rest[..struct_end];
231 let fields: Vec<String> = field_re
232 .captures_iter(struct_body)
233 .map(|cap| {
234 let field_name = cap[1].trim();
235 let field_type = cap[2].trim().trim_end_matches(',');
236 format!("{field_name} ({field_type})")
237 })
238 .collect();
239
240 if !fields.is_empty() {
241 output.push_str(&format!("### {}: {}\n", struct_name, fields.join(", ")));
242 }
243 }
244 }
245
246 output
247}
248
249fn scan_routes() -> String {
251 let routes_file = Path::new("src/routes.rs");
252 if !routes_file.exists() {
253 return String::new();
254 }
255
256 let content = match fs::read_to_string(routes_file) {
257 Ok(c) => c,
258 Err(_) => return String::new(),
259 };
260
261 let routes = generate_routes::parse_routes_file(&content);
262 let mut output = String::new();
263
264 for route in &routes {
265 let method = match route.method {
266 generate_routes::HttpMethod::Get => "GET",
267 generate_routes::HttpMethod::Post => "POST",
268 generate_routes::HttpMethod::Put => "PUT",
269 generate_routes::HttpMethod::Patch => "PATCH",
270 generate_routes::HttpMethod::Delete => "DELETE",
271 };
272
273 let name_suffix = route
274 .name
275 .as_ref()
276 .map(|n| format!(" (name: \"{n}\")"))
277 .unwrap_or_default();
278
279 output.push_str(&format!(
280 "{} {} -> {}::{}{}\n",
281 method, route.path, route.handler_module, route.handler_fn, name_suffix
282 ));
283 }
284
285 output
286}