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//! the Anthropic API for AI-powered two-pass generation from a natural language
5//! description. Handlers call `JsonUi::render_file("views/{name}.json", data)`.
6
7use console::style;
8use std::fs;
9use std::path::Path;
10
11use crate::ai;
12use crate::templates;
13
14pub fn run(name: String, description: Option<String>, no_ai: bool, layout: Option<String>) {
15    let file_name = to_snake_case(&name);
16
17    if !is_valid_identifier(&file_name) {
18        eprintln!(
19            "{} '{}' is not a valid view name",
20            style("Error:").red().bold(),
21            name
22        );
23        std::process::exit(1);
24    }
25
26    let views_dir = Path::new("src/views");
27    let view_file = views_dir.join(format!("{file_name}.json"));
28
29    // Create views/ directory if missing
30    if !views_dir.exists() {
31        if let Err(e) = fs::create_dir_all(views_dir) {
32            eprintln!(
33                "{} Failed to create src/views directory: {}",
34                style("Error:").red().bold(),
35                e
36            );
37            std::process::exit(1);
38        }
39        println!("{} Created src/views/", style("✓").green());
40    }
41
42    // If the JSON view already exists, skip (non-destructive)
43    if view_file.exists() {
44        eprintln!(
45            "{} View '{}' already exists at {}",
46            style("Info:").yellow().bold(),
47            file_name,
48            view_file.display()
49        );
50        std::process::exit(0);
51    }
52
53    let layout_name = layout.as_deref().unwrap_or("dashboard");
54    let title = to_title_case(&file_name);
55
56    let content = if no_ai {
57        templates::json_view_template(&file_name, &title, layout_name)
58    } else {
59        match std::env::var("ANTHROPIC_API_KEY") {
60            Ok(_) => {
61                let desc = description.as_deref().unwrap_or(&title);
62                println!(
63                    "{} Generating view with AI (two passes)...",
64                    style("⏳").cyan()
65                );
66                generate_with_ai(&file_name, &title, layout_name, desc)
67            }
68            Err(_) => {
69                if description.is_some() {
70                    eprintln!(
71                        "{} No ANTHROPIC_API_KEY found, using static template. \
72                         Set the key or use --no-ai to suppress this message.",
73                        style("Info:").yellow().bold(),
74                    );
75                }
76                templates::json_view_template(&file_name, &title, layout_name)
77            }
78        }
79    };
80
81    if let Err(e) = fs::write(&view_file, content) {
82        eprintln!(
83            "{} Failed to write view file: {}",
84            style("Error:").red().bold(),
85            e
86        );
87        std::process::exit(1);
88    }
89    println!("{} Created {}", style("✓").green(), view_file.display());
90
91    // Usage guidance — v2 handler pattern
92    println!();
93    println!(
94        "View {} created successfully!",
95        style(&file_name).cyan().bold()
96    );
97    println!();
98    println!("Usage:");
99    println!("  {} Serve the view from a handler:", style("1.").dim());
100    println!();
101    println!("     #[handler]");
102    println!("     pub async fn {file_name}(req: Request) -> Response {{");
103    println!("         let data = serde_json::json!({{}});");
104    println!("         JsonUi::render_file(\"views/{file_name}.json\", data)");
105    println!("     }}");
106    println!();
107}
108
109/// Orchestrate two-pass AI generation with catalog validation and static fallback.
110///
111/// Pass 1: plain-text component plan via `call_anthropic_plain`.
112/// Pass 2: structured JSON via `call_anthropic_structured` + `catalog.json_schema()`.
113/// On any failure (HTTP error, unparseable spec, catalog validation error), prints a
114/// yellow warning to stderr and falls back to the static template.
115fn generate_with_ai(file_name: &str, title: &str, layout_name: &str, description: &str) -> String {
116    // ── Pass 1: plain-text plan ────────────────────────────────────────────
117    let (sys1, usr1) = ai::build_json_view_pass1(file_name, description);
118    let pass1_result = match ai::call_anthropic_plain(&sys1, &usr1) {
119        Ok(text) => text,
120        Err(e) => {
121            eprintln!(
122                "{} AI Pass 1 failed: {}",
123                style("Warning:").yellow().bold(),
124                e
125            );
126            eprintln!("{}", style("Falling back to static template.").dim());
127            return templates::json_view_template(file_name, title, layout_name);
128        }
129    };
130
131    // ── Pass 2: structured spec ───────────────────────────────────────────
132    let (sys2, usr2) = ai::build_json_view_pass2(&pass1_result);
133    let schema = ferro_json_ui::global_catalog().json_schema().clone();
134    let json_str = match ai::call_anthropic_structured(&sys2, &usr2, schema) {
135        Ok(s) => s,
136        Err(e) => {
137            eprintln!(
138                "{} AI Pass 2 failed: {}",
139                style("Warning:").yellow().bold(),
140                e
141            );
142            eprintln!("{}", style("Falling back to static template.").dim());
143            return templates::json_view_template(file_name, title, layout_name);
144        }
145    };
146
147    // ── Validation (D-03): Spec::from_json → global_catalog().validate ───
148    match ferro_json_ui::Spec::from_json(&json_str) {
149        Err(parse_err) => {
150            eprintln!(
151                "{} Generated spec failed structural parse: {}",
152                style("Warning:").yellow().bold(),
153                parse_err
154            );
155            eprintln!("{}", style("Falling back to static template.").dim());
156            templates::json_view_template(file_name, title, layout_name)
157        }
158        Ok(spec) => match ferro_json_ui::global_catalog().validate(&spec) {
159            Ok(()) => json_str,
160            Err(errors) => {
161                eprintln!(
162                    "{} Generated spec failed catalog validation ({} error{}):",
163                    style("Warning:").yellow().bold(),
164                    errors.len(),
165                    if errors.len() == 1 { "" } else { "s" }
166                );
167                for err in &errors {
168                    eprintln!("  - {err}");
169                }
170                eprintln!("{}", style("Falling back to static template.").dim());
171                templates::json_view_template(file_name, title, layout_name)
172            }
173        },
174    }
175}
176
177fn is_valid_identifier(name: &str) -> bool {
178    if name.is_empty() {
179        return false;
180    }
181
182    let mut chars = name.chars();
183
184    match chars.next() {
185        Some(c) if c.is_alphabetic() || c == '_' => {}
186        _ => return false,
187    }
188
189    chars.all(|c| c.is_alphanumeric() || c == '_')
190}
191
192fn to_snake_case(s: &str) -> String {
193    let mut result = String::new();
194    for (i, c) in s.chars().enumerate() {
195        if c.is_uppercase() {
196            if i > 0 {
197                result.push('_');
198            }
199            result.push(c.to_lowercase().next().unwrap());
200        } else {
201            result.push(c);
202        }
203    }
204    result
205}
206
207fn to_title_case(s: &str) -> String {
208    s.split('_')
209        .map(|word| {
210            let mut chars = word.chars();
211            match chars.next() {
212                None => String::new(),
213                Some(first) => {
214                    let mut result = first.to_uppercase().to_string();
215                    result.extend(chars);
216                    result
217                }
218            }
219        })
220        .collect::<Vec<_>>()
221        .join(" ")
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn to_snake_case_basic() {
230        assert_eq!(to_snake_case("UserList"), "user_list");
231        assert_eq!(to_snake_case("dashboard"), "dashboard");
232    }
233
234    #[test]
235    fn to_title_case_basic() {
236        assert_eq!(to_title_case("user_list"), "User List");
237        assert_eq!(to_title_case("dashboard"), "Dashboard");
238    }
239
240    #[test]
241    fn is_valid_identifier_accepts_snake_case() {
242        assert!(is_valid_identifier("user_list"));
243        assert!(is_valid_identifier("dashboard"));
244    }
245
246    #[test]
247    fn is_valid_identifier_rejects_invalid() {
248        assert!(!is_valid_identifier(""));
249        assert!(!is_valid_identifier("1bad"));
250        assert!(!is_valid_identifier("has-dash"));
251    }
252
253    // Integration-ish: the fallback path writes a parseable spec.
254    // We do NOT invoke `run` here because it calls std::process::exit.
255    // Instead we exercise the static template path directly.
256    #[test]
257    fn static_fallback_produces_valid_spec() {
258        let out = crate::templates::json_view_template("dashboard", "Dashboard", "dashboard");
259        let spec = ferro_json_ui::Spec::from_json(&out);
260        assert!(spec.is_ok(), "static fallback must parse: {spec:?}");
261    }
262}