use console::style;
use ferro_ai::AiConfig;
use std::fs;
use std::path::Path;
use crate::templates;
pub fn run(
name: String,
description: Option<String>,
no_ai: bool,
layout: Option<String>,
from_service_json: 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 if let Some(ref _path) = from_service_json {
#[cfg(feature = "projections")]
{
let path = _path;
let json_content = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"{} Failed to read service JSON file '{}': {}",
style("Error:").red().bold(),
path,
e
);
std::process::exit(1);
}
};
let service: ferro_projections::ServiceDef = match serde_json::from_str(&json_content) {
Ok(s) => s,
Err(e) => {
eprintln!(
"{} Failed to parse ServiceDef from '{}': {}",
style("Error:").red().bold(),
path,
e
);
std::process::exit(1);
}
};
println!(
"{} Rendering ServiceDef from {} ...",
style("⏳").cyan(),
path
);
render_service_def(&service, &file_name, &title, layout_name)
}
#[cfg(not(feature = "projections"))]
{
eprintln!(
"{} make:json-view --from-service-json requires the `projections` feature",
style("Error:").red().bold()
);
std::process::exit(1);
}
} else {
match AiConfig::from_env() {
Ok(_) => {
let desc = description.as_deref().unwrap_or(&title);
#[cfg(feature = "projections")]
{
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 write_content(
&view_file,
templates::json_view_template(&file_name, &title, layout_name),
&file_name,
);
}
};
let cwd =
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
println!("{} Generating ServiceDef via AI...", style("⏳").cyan());
let desc_owned = desc.to_string();
match rt.block_on(ferro_mcp::tools::ai_scaffold::scaffold_core(
&desc_owned,
&cwd,
)) {
Ok(service) => {
println!("{} Rendering projection spec...", style("⏳").cyan());
render_service_def(&service, &file_name, &title, layout_name)
}
Err(e) => {
eprintln!(
"{} AI scaffold failed: {}",
style("Warning:").yellow().bold(),
e
);
eprintln!("{}", style("Falling back to static template.").dim());
templates::json_view_template(&file_name, &title, layout_name)
}
}
}
#[cfg(not(feature = "projections"))]
{
let _ = desc;
eprintln!(
"{} AI generation requires the `projections` feature. Using static template.",
style("Info:").yellow().bold()
);
templates::json_view_template(&file_name, &title, layout_name)
}
}
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)
}
}
};
write_content(&view_file, content, &file_name);
}
fn write_content(view_file: &Path, content: String, file_name: &str) {
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!();
}
#[cfg(feature = "projections")]
fn render_service_def(
service: &ferro_projections::ServiceDef,
file_name: &str,
title: &str,
layout_name: &str,
) -> String {
use ferro_json_ui::{Spec, VisualContext};
use ferro_projections::derive_intents;
let intents = derive_intents(service);
let ctx = VisualContext::default();
match Spec::from_service_def(service, &intents, &ctx) {
Err(e) => {
eprintln!(
"{} Projection render failed: {e}",
style("Warning:").yellow().bold()
);
eprintln!("{}", style("Falling back to static template.").dim());
templates::json_view_template(file_name, title, layout_name)
}
Ok(spec) => match serde_json::to_string_pretty(&spec) {
Err(e) => {
eprintln!(
"{} Spec serialization failed: {e}",
style("Warning:").yellow().bold()
);
eprintln!("{}", style("Falling back to static template.").dim());
templates::json_view_template(file_name, title, layout_name)
}
Ok(json_str) => {
match Spec::from_json(&json_str) {
Err(e) => {
eprintln!(
"{} Spec parse failed: {e}",
style("Warning:").yellow().bold()
);
eprintln!("{}", style("Falling back to static template.").dim());
templates::json_view_template(file_name, title, layout_name)
}
Ok(_) => json_str,
}
}
},
}
}
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:?}");
}
}