ferro_cli/commands/
make_json_view.rs1use 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 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 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 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
109fn generate_with_ai(file_name: &str, title: &str, layout_name: &str, description: &str) -> String {
116 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 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 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 #[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}