Skip to main content

ferro_cli/commands/
make_json_view.rs

1//! `ferro make:json-view` command implementation.
2//!
3//! Generates a JSON-UI v2 spec file (`src/views/{name}.json`), optionally using
4//! an AI provider for two-pass generation from a natural language description.
5//! Handlers call `JsonUi::render_file("views/{name}.json", data)`.
6
7use 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    // Create views/ directory if missing
32    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 the JSON view already exists, skip (non-destructive)
45    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    // Usage guidance — v2 handler pattern
94    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
111/// Orchestrate two-pass AI generation with catalog validation and static fallback.
112///
113/// Pass 1: plain-text component plan via `client.complete` (schema: None).
114/// Pass 2: structured JSON via `client.complete` + `global_catalog().json_schema()`.
115/// On any failure (runtime, HTTP error, unparseable spec, catalog validation error),
116/// prints a yellow warning to stderr and falls back to the static template.
117fn 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    // One runtime, reused across both passes (D-01). ferro-cli main() is sync (no #[tokio::main]),
125    // so Runtime::new() is safe — no nested-runtime panic.
126    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    // ── Pass 1: plain-text plan (schema: None, max_tokens 1024) ──────────────
140    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    // ── Pass 2: structured spec against the catalog schema (max_tokens 4096) ─
168    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        // Catalog runtime schema is the validation source of truth (D-02) — NOT schemars.
180        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    // ── Validation (D-03): unchanged from the current implementation ─────────
198    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
227/// Build Pass 1 prompts for JSON-UI v2 view generation (plain-text component plan).
228///
229/// Returns `(system_prompt, user_prompt)` ready for `client.complete` with `schema: None`.
230fn 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
251/// Build Pass 2 prompts for JSON-UI v2 view generation (structured spec).
252///
253/// Returns `(system_prompt, user_prompt)` ready for `client.complete` with the catalog schema.
254/// Pass 2 receives the plain-text plan from Pass 1 and produces a structured JSON spec.
255fn 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    // Integration-ish: the fallback path writes a parseable spec.
348    // We do NOT invoke `run` here because it calls std::process::exit.
349    // Instead we exercise the static template path directly.
350    #[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}