use console::style;
use ferro_ai::client::{Message, Role};
use ferro_ai::{AiConfig, CompletionRequest};
use ferro_json_ui::global_catalog;
use std::fs;
use std::path::Path;
use crate::templates;
pub fn run(name: String, description: Option<String>, no_ai: bool, layout: Option<String>) {
let file_name = to_snake_case(&name);
if !is_valid_identifier(&file_name) {
eprintln!(
"{} '{}' is not a valid view name",
style("Error:").red().bold(),
name
);
std::process::exit(1);
}
let views_dir = Path::new("src/views");
let view_file = views_dir.join(format!("{file_name}.json"));
if !views_dir.exists() {
if let Err(e) = fs::create_dir_all(views_dir) {
eprintln!(
"{} Failed to create src/views directory: {}",
style("Error:").red().bold(),
e
);
std::process::exit(1);
}
println!("{} Created src/views/", style("✓").green());
}
if view_file.exists() {
eprintln!(
"{} View '{}' already exists at {}",
style("Info:").yellow().bold(),
file_name,
view_file.display()
);
std::process::exit(0);
}
let layout_name = layout.as_deref().unwrap_or("dashboard");
let title = to_title_case(&file_name);
let content = if no_ai {
templates::json_view_template(&file_name, &title, layout_name)
} else {
match AiConfig::from_env() {
Ok(client) => {
let desc = description.as_deref().unwrap_or(&title);
println!(
"{} Generating view with AI (two passes)...",
style("⏳").cyan()
);
generate_with_ai(client.as_ref(), &file_name, &title, layout_name, desc)
}
Err(_) => {
if description.is_some() {
eprintln!(
"{} No AI provider configured. Set FERRO_AI_API_KEY (and optionally \
FERRO_AI_PROVIDER / FERRO_AI_MODEL), or use --no-ai to suppress this message.",
style("Info:").yellow().bold(),
);
}
templates::json_view_template(&file_name, &title, layout_name)
}
}
};
if let Err(e) = fs::write(&view_file, content) {
eprintln!(
"{} Failed to write view file: {}",
style("Error:").red().bold(),
e
);
std::process::exit(1);
}
println!("{} Created {}", style("✓").green(), view_file.display());
println!();
println!(
"View {} created successfully!",
style(&file_name).cyan().bold()
);
println!();
println!("Usage:");
println!(" {} Serve the view from a handler:", style("1.").dim());
println!();
println!(" #[handler]");
println!(" pub async fn {file_name}(req: Request) -> Response {{");
println!(" let data = serde_json::json!({{}});");
println!(" JsonUi::render_file(\"views/{file_name}.json\", data)");
println!(" }}");
println!();
}
fn generate_with_ai(
client: &dyn ferro_ai::LlmClient,
file_name: &str,
title: &str,
layout_name: &str,
description: &str,
) -> String {
let rt = match tokio::runtime::Runtime::new() {
Ok(r) => r,
Err(e) => {
eprintln!(
"{} Failed to create tokio runtime: {}",
style("Warning:").yellow().bold(),
e
);
eprintln!("{}", style("Falling back to static template.").dim());
return templates::json_view_template(file_name, title, layout_name);
}
};
let (sys1, usr1) = build_json_view_pass1(file_name, description);
let req1 = CompletionRequest {
system: Some(sys1),
messages: vec![Message {
role: Role::User,
content: usr1,
tool_call_id: None,
}],
max_tokens: 1024,
model_override: None,
schema: None,
tools: None,
tool_choice: None,
};
let pass1_result = match rt.block_on(client.complete(req1)) {
Ok(text) => text,
Err(e) => {
eprintln!(
"{} AI Pass 1 failed: {}",
style("Warning:").yellow().bold(),
e
);
eprintln!("{}", style("Falling back to static template.").dim());
return templates::json_view_template(file_name, title, layout_name);
}
};
let (sys2, usr2) = build_json_view_pass2(&pass1_result);
let schema = ferro_json_ui::global_catalog().json_schema().clone();
let req2 = CompletionRequest {
system: Some(sys2),
messages: vec![Message {
role: Role::User,
content: usr2,
tool_call_id: None,
}],
max_tokens: 4096,
model_override: None,
schema: Some(schema),
tools: None,
tool_choice: None,
};
let json_str = match rt.block_on(client.complete(req2)) {
Ok(s) => s,
Err(e) => {
eprintln!(
"{} AI Pass 2 failed: {}",
style("Warning:").yellow().bold(),
e
);
eprintln!("{}", style("Falling back to static template.").dim());
return templates::json_view_template(file_name, title, layout_name);
}
};
match ferro_json_ui::Spec::from_json(&json_str) {
Err(parse_err) => {
eprintln!(
"{} Generated spec failed structural parse: {}",
style("Warning:").yellow().bold(),
parse_err
);
eprintln!("{}", style("Falling back to static template.").dim());
templates::json_view_template(file_name, title, layout_name)
}
Ok(spec) => match ferro_json_ui::global_catalog().validate(&spec) {
Ok(()) => json_str,
Err(errors) => {
eprintln!(
"{} Generated spec failed catalog validation ({} error{}):",
style("Warning:").yellow().bold(),
errors.len(),
if errors.len() == 1 { "" } else { "s" }
);
for err in &errors {
eprintln!(" - {err}");
}
eprintln!("{}", style("Falling back to static template.").dim());
templates::json_view_template(file_name, title, layout_name)
}
},
}
}
fn build_json_view_pass1(name: &str, description: &str) -> (String, String) {
let catalog = global_catalog();
let catalog_prompt = catalog.prompt();
let system = format!(
"You are a JSON-UI v2 view planner for the Ferro framework.\n\n\
{catalog_prompt}\n\n\
Given a view name and description, produce a concise plain-text component plan: \
which components to use, what data each displays, what actions are present. \
Do not emit any JSON or code — only a human-readable plan."
);
let user = format!(
"View name: {name}\n\
Description: {description}\n\n\
Describe the component plan for this view."
);
(system, user)
}
fn build_json_view_pass2(pass1_result: &str) -> (String, String) {
let system = format!(
"You are a JSON-UI v2 spec generator for the Ferro framework.\n\n\
Component plan from previous step:\n{pass1_result}\n\n\
Generate the complete v2 JSON spec matching this plan. \
Root element id must be \"root\". \
All element ids are unique strings. Use flat elements map — no nesting."
);
let user =
"Generate the complete JSON-UI v2 spec for the view described in the component plan."
.to_string();
(system, user)
}
fn is_valid_identifier(name: &str) -> bool {
if name.is_empty() {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(c.to_lowercase().next().unwrap());
} else {
result.push(c);
}
}
result
}
fn to_title_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let mut result = first.to_uppercase().to_string();
result.extend(chars);
result
}
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_snake_case_basic() {
assert_eq!(to_snake_case("UserList"), "user_list");
assert_eq!(to_snake_case("dashboard"), "dashboard");
}
#[test]
fn to_title_case_basic() {
assert_eq!(to_title_case("user_list"), "User List");
assert_eq!(to_title_case("dashboard"), "Dashboard");
}
#[test]
fn is_valid_identifier_accepts_snake_case() {
assert!(is_valid_identifier("user_list"));
assert!(is_valid_identifier("dashboard"));
}
#[test]
fn is_valid_identifier_rejects_invalid() {
assert!(!is_valid_identifier(""));
assert!(!is_valid_identifier("1bad"));
assert!(!is_valid_identifier("has-dash"));
}
#[test]
fn static_fallback_produces_valid_spec() {
let out = crate::templates::json_view_template("dashboard", "Dashboard", "dashboard");
let spec = ferro_json_ui::Spec::from_json(&out);
assert!(spec.is_ok(), "static fallback must parse: {spec:?}");
}
}