Skip to main content

ferro_cli/
ai.rs

1//! AI-powered view generation via the Anthropic API.
2//!
3//! Provides two main functions:
4//! - `call_anthropic`: Makes a blocking request to the Anthropic Messages API.
5//! - `build_view_context`: Assembles a prompt with component catalog, project models, and routes.
6
7use ferro_json_ui::COMPONENT_CATALOG;
8use regex::Regex;
9use std::fs;
10use std::path::Path;
11
12use crate::commands::generate_routes;
13
14/// Call the Anthropic Messages API with separate system and user prompts.
15///
16/// Reads `ANTHROPIC_API_KEY` from environment. Model defaults to `claude-sonnet-4-5`
17/// but can be overridden via `FERRO_AI_MODEL`.
18///
19/// Uses Anthropic best practices: system prompt with cache_control, assistant prefill,
20/// temperature 0.2 for deterministic output, and 60-second HTTP timeout.
21pub 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    // Prepend the assistant prefill that is not included in the response
81    Ok(format!("//!{response_text}"))
82}
83
84/// Assemble system and user prompts for AI view generation.
85///
86/// Returns `(system_prompt, user_prompt)` where:
87/// - System prompt: role, rules, component catalog, few-shot example (static, cacheable)
88/// - User prompt: project models, routes, view name and description (dynamic, per-request)
89pub fn build_view_context(name: &str, description: &str) -> (String, String) {
90    // System prompt: static content that benefits from prompt caching
91    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    // User prompt: dynamic content that changes per request
151    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
176/// Scan `src/models/*.rs` and extract struct fields using regex.
177fn 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        // Find struct definitions
208        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            // Find the closing brace for this struct
213            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
249/// Scan `src/routes.rs` and format route definitions.
250fn 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}