1use console::style;
13use ferro_ai::AiConfig;
14use std::fs;
15use std::path::Path;
16
17use crate::templates;
18
19pub fn run(
20 name: String,
21 description: Option<String>,
22 no_ai: bool,
23 layout: Option<String>,
24 from_service_json: Option<String>,
25) {
26 let file_name = to_snake_case(&name);
27
28 if !is_valid_identifier(&file_name) {
29 eprintln!(
30 "{} '{}' is not a valid view name",
31 style("Error:").red().bold(),
32 name
33 );
34 std::process::exit(1);
35 }
36
37 let views_dir = Path::new("src/views");
38 let view_file = views_dir.join(format!("{file_name}.json"));
39
40 if !views_dir.exists() {
42 if let Err(e) = fs::create_dir_all(views_dir) {
43 eprintln!(
44 "{} Failed to create src/views directory: {}",
45 style("Error:").red().bold(),
46 e
47 );
48 std::process::exit(1);
49 }
50 println!("{} Created src/views/", style("✓").green());
51 }
52
53 if view_file.exists() {
55 eprintln!(
56 "{} View '{}' already exists at {}",
57 style("Info:").yellow().bold(),
58 file_name,
59 view_file.display()
60 );
61 std::process::exit(0);
62 }
63
64 let layout_name = layout.as_deref().unwrap_or("dashboard");
65 let title = to_title_case(&file_name);
66
67 let content = if no_ai {
68 templates::json_view_template(&file_name, &title, layout_name)
69 } else if let Some(ref _path) = from_service_json {
70 #[cfg(feature = "projections")]
72 {
73 let path = _path;
74 let json_content = match fs::read_to_string(path) {
75 Ok(s) => s,
76 Err(e) => {
77 eprintln!(
78 "{} Failed to read service JSON file '{}': {}",
79 style("Error:").red().bold(),
80 path,
81 e
82 );
83 std::process::exit(1);
84 }
85 };
86 let service: ferro_projections::ServiceDef = match serde_json::from_str(&json_content) {
87 Ok(s) => s,
88 Err(e) => {
89 eprintln!(
90 "{} Failed to parse ServiceDef from '{}': {}",
91 style("Error:").red().bold(),
92 path,
93 e
94 );
95 std::process::exit(1);
96 }
97 };
98 println!(
99 "{} Rendering ServiceDef from {} ...",
100 style("⏳").cyan(),
101 path
102 );
103 render_service_def(&service, &file_name, &title, layout_name)
104 }
105 #[cfg(not(feature = "projections"))]
106 {
107 eprintln!(
108 "{} make:json-view --from-service-json requires the `projections` feature",
109 style("Error:").red().bold()
110 );
111 std::process::exit(1);
112 }
113 } else {
114 match AiConfig::from_env() {
115 Ok(_) => {
116 let desc = description.as_deref().unwrap_or(&title);
117 #[cfg(feature = "projections")]
118 {
119 let rt = match tokio::runtime::Runtime::new() {
121 Ok(r) => r,
122 Err(e) => {
123 eprintln!(
124 "{} Failed to create tokio runtime: {}",
125 style("Warning:").yellow().bold(),
126 e
127 );
128 eprintln!("{}", style("Falling back to static template.").dim());
129 return write_content(
130 &view_file,
131 templates::json_view_template(&file_name, &title, layout_name),
132 &file_name,
133 );
134 }
135 };
136 let cwd =
137 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
138 println!("{} Generating ServiceDef via AI...", style("⏳").cyan());
139 let desc_owned = desc.to_string();
140 match rt.block_on(ferro_mcp::tools::ai_scaffold::scaffold_core(
141 &desc_owned,
142 &cwd,
143 )) {
144 Ok(service) => {
145 println!("{} Rendering projection spec...", style("⏳").cyan());
146 render_service_def(&service, &file_name, &title, layout_name)
147 }
148 Err(e) => {
149 eprintln!(
150 "{} AI scaffold failed: {}",
151 style("Warning:").yellow().bold(),
152 e
153 );
154 eprintln!("{}", style("Falling back to static template.").dim());
155 templates::json_view_template(&file_name, &title, layout_name)
156 }
157 }
158 }
159 #[cfg(not(feature = "projections"))]
160 {
161 let _ = desc;
163 eprintln!(
164 "{} AI generation requires the `projections` feature. Using static template.",
165 style("Info:").yellow().bold()
166 );
167 templates::json_view_template(&file_name, &title, layout_name)
168 }
169 }
170 Err(_) => {
171 if description.is_some() {
172 eprintln!(
173 "{} No AI provider configured. Set FERRO_AI_API_KEY (and optionally \
174 FERRO_AI_PROVIDER / FERRO_AI_MODEL), or use --no-ai to suppress this message.",
175 style("Info:").yellow().bold(),
176 );
177 }
178 templates::json_view_template(&file_name, &title, layout_name)
179 }
180 }
181 };
182
183 write_content(&view_file, content, &file_name);
184}
185
186fn write_content(view_file: &Path, content: String, file_name: &str) {
188 if let Err(e) = fs::write(view_file, content) {
189 eprintln!(
190 "{} Failed to write view file: {}",
191 style("Error:").red().bold(),
192 e
193 );
194 std::process::exit(1);
195 }
196 println!("{} Created {}", style("✓").green(), view_file.display());
197
198 println!();
200 println!(
201 "View {} created successfully!",
202 style(file_name).cyan().bold()
203 );
204 println!();
205 println!("Usage:");
206 println!(" {} Serve the view from a handler:", style("1.").dim());
207 println!();
208 println!(" #[handler]");
209 println!(" pub async fn {file_name}(req: Request) -> Response {{");
210 println!(" let data = serde_json::json!({{}});");
211 println!(" JsonUi::render_file(\"views/{file_name}.json\", data)");
212 println!(" }}");
213 println!();
214}
215
216#[cfg(feature = "projections")]
222fn render_service_def(
223 service: &ferro_projections::ServiceDef,
224 file_name: &str,
225 title: &str,
226 layout_name: &str,
227) -> String {
228 use ferro_json_ui::{Spec, VisualContext};
229 use ferro_projections::derive_intents;
230
231 let intents = derive_intents(service);
232 let ctx = VisualContext::default();
233 match Spec::from_service_def(service, &intents, &ctx) {
234 Err(e) => {
235 eprintln!(
236 "{} Projection render failed: {e}",
237 style("Warning:").yellow().bold()
238 );
239 eprintln!("{}", style("Falling back to static template.").dim());
240 templates::json_view_template(file_name, title, layout_name)
241 }
242 Ok(spec) => match serde_json::to_string_pretty(&spec) {
243 Err(e) => {
244 eprintln!(
245 "{} Spec serialization failed: {e}",
246 style("Warning:").yellow().bold()
247 );
248 eprintln!("{}", style("Falling back to static template.").dim());
249 templates::json_view_template(file_name, title, layout_name)
250 }
251 Ok(json_str) => {
252 match Spec::from_json(&json_str) {
254 Err(e) => {
255 eprintln!(
256 "{} Spec parse failed: {e}",
257 style("Warning:").yellow().bold()
258 );
259 eprintln!("{}", style("Falling back to static template.").dim());
260 templates::json_view_template(file_name, title, layout_name)
261 }
262 Ok(_) => json_str,
263 }
264 }
265 },
266 }
267}
268
269fn is_valid_identifier(name: &str) -> bool {
270 if name.is_empty() {
271 return false;
272 }
273
274 let mut chars = name.chars();
275
276 match chars.next() {
277 Some(c) if c.is_alphabetic() || c == '_' => {}
278 _ => return false,
279 }
280
281 chars.all(|c| c.is_alphanumeric() || c == '_')
282}
283
284fn to_snake_case(s: &str) -> String {
285 let mut result = String::new();
286 for (i, c) in s.chars().enumerate() {
287 if c.is_uppercase() {
288 if i > 0 {
289 result.push('_');
290 }
291 result.push(c.to_lowercase().next().unwrap());
292 } else {
293 result.push(c);
294 }
295 }
296 result
297}
298
299fn to_title_case(s: &str) -> String {
300 s.split('_')
301 .map(|word| {
302 let mut chars = word.chars();
303 match chars.next() {
304 None => String::new(),
305 Some(first) => {
306 let mut result = first.to_uppercase().to_string();
307 result.extend(chars);
308 result
309 }
310 }
311 })
312 .collect::<Vec<_>>()
313 .join(" ")
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn to_snake_case_basic() {
322 assert_eq!(to_snake_case("UserList"), "user_list");
323 assert_eq!(to_snake_case("dashboard"), "dashboard");
324 }
325
326 #[test]
327 fn to_title_case_basic() {
328 assert_eq!(to_title_case("user_list"), "User List");
329 assert_eq!(to_title_case("dashboard"), "Dashboard");
330 }
331
332 #[test]
333 fn is_valid_identifier_accepts_snake_case() {
334 assert!(is_valid_identifier("user_list"));
335 assert!(is_valid_identifier("dashboard"));
336 }
337
338 #[test]
339 fn is_valid_identifier_rejects_invalid() {
340 assert!(!is_valid_identifier(""));
341 assert!(!is_valid_identifier("1bad"));
342 assert!(!is_valid_identifier("has-dash"));
343 }
344
345 #[test]
349 fn static_fallback_produces_valid_spec() {
350 let out = crate::templates::json_view_template("dashboard", "Dashboard", "dashboard");
351 let spec = ferro_json_ui::Spec::from_json(&out);
352 assert!(spec.is_ok(), "static fallback must parse: {spec:?}");
353 }
354}