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`) from a `ServiceDef`
4//! via the deterministic `Spec::from_service_def` renderer. Two ServiceDef sources
5//! are supported:
6//!
7//! - NL description (`-d "<text>"`) → `scaffold_core` → `ServiceDef`
8//! - Pre-serialized JSON file (`--from-service-json <path>`) → `ServiceDef`
9//!
10//! Handlers call `JsonUi::render_file("views/{name}.json", data)`.
11
12use 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    // Create views/ directory if missing
41    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 the JSON view already exists, skip (non-destructive)
54    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        // --from-service-json path: deserialize ServiceDef from JSON file, render deterministically.
71        #[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                    // NL → ServiceDef via scaffold_core, then deterministic render.
120                    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                    // projections feature disabled — note and fall back.
162                    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
186/// Write spec content to the view file and print usage guidance.
187fn 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    // Usage guidance — v2 handler pattern
199    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/// Render a `ServiceDef` deterministically to a JSON-UI v2 spec string.
217///
218/// Uses `derive_intents` + `Spec::from_service_def` (FieldMeaning-driven component
219/// dispatch). On any render/serialize/parse failure, prints a yellow warning and
220/// returns the static template fallback.
221#[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                // Write-gate re-parse (D-02): validate the serialized JSON form.
253                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    // Integration-ish: the fallback path writes a parseable spec.
346    // We do NOT invoke `run` here because it calls std::process::exit.
347    // Instead we exercise the static template path directly.
348    #[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}