1use std::path::{Path, PathBuf};
21
22use anyhow::{anyhow, Result};
23use serde_json::{Map, Value};
24use syn::parse::Parser;
25use syn::visit::{self, Visit};
26use syn::{Expr, ExprCall, ExprLit, ExprMethodCall, File, ImplItemFn, ItemFn, Lit};
27
28pub fn run(file: String, dry_run: bool) -> Result<()> {
30 let path = PathBuf::from(&file);
31 if !path.exists() {
32 return Err(anyhow!("controller file not found: {file}"));
33 }
34 let src = std::fs::read_to_string(&path)?;
35 let parsed: File =
36 syn::parse_file(&src).map_err(|e| anyhow!("failed to parse {file} as Rust source: {e}"))?;
37
38 if file_already_migrated(&parsed, &src) {
39 eprintln!(
40 "warning: {file} appears already migrated (uses JsonUi::render_file or \
41 contains a json-ui:migrate-v1 TODO marker). No changes made."
42 );
43 return Ok(());
44 }
45
46 let mut visitor = MigrationVisitor::new(&path);
47 visitor.visit_file(&parsed);
48
49 if visitor.specs.is_empty() && visitor.todo_handlers.is_empty() {
50 eprintln!(
51 "warning: {file} contains no recognizable v1 patterns \
52 (make_node / JsonUiView::new). No changes made."
53 );
54 return Ok(());
55 }
56
57 let outputs = visitor.finalize(&src)?;
58
59 if dry_run {
60 print_dry_run(&outputs);
61 return Ok(());
62 }
63
64 for SpecOutput {
65 path: spec_path,
66 json,
67 } in &outputs.specs
68 {
69 if let Some(parent) = spec_path.parent() {
70 std::fs::create_dir_all(parent)?;
71 }
72 std::fs::write(spec_path, json)?;
73 eprintln!("wrote {}", spec_path.display());
74 }
75 std::fs::write(&path, &outputs.rewritten_controller)?;
76 eprintln!("rewrote {}", path.display());
77 Ok(())
78}
79
80fn file_already_migrated(parsed: &File, raw_src: &str) -> bool {
90 if raw_src.contains("// TODO: ferro json-ui:migrate-v1 could not auto-translate") {
91 return true;
92 }
93 struct Detector(bool);
94 impl<'ast> Visit<'ast> for Detector {
95 fn visit_expr_call(&mut self, call: &'ast ExprCall) {
96 let s = quote::quote!(#call).to_string();
97 if s.contains("JsonUi :: render_file") || s.contains("JsonUi::render_file") {
98 self.0 = true;
99 }
100 visit::visit_expr_call(self, call);
101 }
102 }
103 let mut d = Detector(false);
104 d.visit_file(parsed);
105 d.0
106}
107
108struct MigrationOutputs {
110 specs: Vec<SpecOutput>,
111 rewritten_controller: String,
112}
113
114struct SpecOutput {
115 path: PathBuf,
116 json: String,
117}
118
119fn print_dry_run(out: &MigrationOutputs) {
120 println!("=== dry-run: proposed JSON specs ===");
121 for spec in &out.specs {
122 println!("--- {} ---", spec.path.display());
123 println!("{}", spec.json);
124 }
125 println!("=== dry-run: rewritten controller ===");
126 println!("{}", out.rewritten_controller);
127}
128
129struct MigrationVisitor {
132 specs: Vec<HandlerMigration>,
134 todo_handlers: Vec<String>,
136 controller_path: PathBuf,
138}
139
140#[derive(Debug)]
141struct HandlerMigration {
142 handler_fn_name: String,
143 spec_path: PathBuf,
144 spec_json: Value,
145}
146
147impl MigrationVisitor {
148 fn new(controller_path: &Path) -> Self {
149 Self {
150 specs: Vec::new(),
151 todo_handlers: Vec::new(),
152 controller_path: controller_path.to_path_buf(),
153 }
154 }
155
156 fn finalize(self, original_src: &str) -> Result<MigrationOutputs> {
157 let mut rewritten = String::from(original_src);
158 for handler in &self.todo_handlers {
161 let marker =
162 "// TODO: ferro json-ui:migrate-v1 could not auto-translate this handler\n";
163 rewritten = inject_todo_above_handler(&rewritten, handler, marker);
164 }
165 for migration in &self.specs {
166 rewritten = rewrite_handler_body(
167 &rewritten,
168 &migration.handler_fn_name,
169 migration.spec_path.to_string_lossy().as_ref(),
170 )?;
171 }
172
173 let specs: Vec<SpecOutput> = self
174 .specs
175 .into_iter()
176 .map(|m| SpecOutput {
177 path: m.spec_path,
178 json: serde_json::to_string_pretty(&m.spec_json).unwrap(),
179 })
180 .collect();
181 Ok(MigrationOutputs {
182 specs,
183 rewritten_controller: rewritten,
184 })
185 }
186}
187
188impl<'ast> Visit<'ast> for MigrationVisitor {
189 fn visit_item_fn(&mut self, item: &'ast ItemFn) {
190 let handler_name = item.sig.ident.to_string();
191 match try_migrate_handler(&item.block, &handler_name, &self.controller_path) {
192 HandlerResult::Migrated(spec_path, spec_json) => {
193 self.specs.push(HandlerMigration {
194 handler_fn_name: handler_name,
195 spec_path,
196 spec_json,
197 });
198 }
199 HandlerResult::Unsupported => {
200 self.todo_handlers.push(handler_name);
201 }
202 HandlerResult::NotAHandler => {}
203 }
204 visit::visit_item_fn(self, item);
205 }
206
207 fn visit_impl_item_fn(&mut self, item: &'ast ImplItemFn) {
208 let handler_name = item.sig.ident.to_string();
211 if contains_jsonuiview_new(&item.block) {
212 self.todo_handlers.push(handler_name);
213 }
214 visit::visit_impl_item_fn(self, item);
215 }
216}
217
218#[derive(Debug)]
219enum HandlerResult {
220 Migrated(PathBuf, Value),
221 Unsupported,
222 NotAHandler,
223}
224
225fn try_migrate_handler(
226 block: &syn::Block,
227 handler_name: &str,
228 controller_path: &Path,
229) -> HandlerResult {
230 let body_tokens = quote::quote!(#block).to_string();
231 if !body_tokens.contains("JsonUiView") {
232 return HandlerResult::NotAHandler;
233 }
234 if body_tokens.contains("Spec :: builder")
237 || body_tokens.contains("Spec::builder")
238 || has_dynamic_key(&body_tokens)
239 {
240 return HandlerResult::Unsupported;
241 }
242 if has_runtime_branch(block) {
243 return HandlerResult::Unsupported;
244 }
245
246 let Some(view) = extract_view_call(block) else {
247 return HandlerResult::Unsupported;
248 };
249
250 let mut elements = Map::<String, Value>::new();
255
256 let top_ids = flatten_nodes(view.nodes, &mut elements);
257 if top_ids.len() != 1 {
258 return HandlerResult::Unsupported;
259 }
260 let root = top_ids.into_iter().next().expect("len == 1 checked above");
261
262 let mut spec = Map::new();
263 spec.insert(
264 "$schema".to_string(),
265 Value::String("ferro-json-ui/v2".to_string()),
266 );
267 if let Some(title) = view.title {
268 spec.insert("title".to_string(), Value::String(title));
269 }
270 if let Some(layout) = view.layout {
271 spec.insert("layout".to_string(), Value::String(layout));
272 }
273 spec.insert("root".to_string(), Value::String(root));
274 spec.insert("elements".to_string(), Value::Object(elements));
275
276 let module_name = controller_path
277 .file_stem()
278 .and_then(|s| s.to_str())
279 .unwrap_or("unknown")
280 .to_string();
281 let spec_path: PathBuf = [
282 "src",
283 "views",
284 &module_name,
285 &format!("{handler_name}.json"),
286 ]
287 .iter()
288 .collect();
289
290 HandlerResult::Migrated(spec_path, Value::Object(spec))
291}
292
293fn flatten_nodes(nodes: Vec<ExtractedNode>, elements: &mut Map<String, Value>) -> Vec<String> {
294 let mut ids = Vec::with_capacity(nodes.len());
295 for node in nodes {
296 let child_ids = flatten_nodes(node.children_nodes, elements);
297 let mut el_obj = Map::new();
298 el_obj.insert(
299 "type".to_string(),
300 Value::String(node.component_type.clone()),
301 );
302 if !node.props.is_empty() {
303 el_obj.insert("props".to_string(), Value::Object(node.props));
304 }
305 if !child_ids.is_empty() {
306 el_obj.insert(
307 "children".to_string(),
308 Value::Array(child_ids.into_iter().map(Value::String).collect()),
309 );
310 }
311 if let Some(action) = node.action {
312 el_obj.insert("action".to_string(), action);
313 }
314 ids.push(node.id.clone());
315 elements.insert(node.id, Value::Object(el_obj));
316 }
317 ids
318}
319
320#[derive(Debug, Default)]
323struct ExtractedView {
324 title: Option<String>,
325 layout: Option<String>,
326 nodes: Vec<ExtractedNode>,
327}
328
329#[derive(Debug)]
330struct ExtractedNode {
331 id: String,
332 component_type: String,
333 props: Map<String, Value>,
334 children_nodes: Vec<ExtractedNode>,
338 action: Option<Value>,
339}
340
341fn extract_view_call(block: &syn::Block) -> Option<ExtractedView> {
346 let chain = find_view_chain(block)?;
347 extract_chain(&chain)
348}
349
350fn find_view_chain(block: &syn::Block) -> Option<Expr> {
354 struct Finder {
355 found: Option<Expr>,
356 }
357 impl<'ast> Visit<'ast> for Finder {
358 fn visit_expr(&mut self, expr: &'ast Expr) {
359 if self.found.is_some() {
360 return;
361 }
362 if is_jsonuiview_chain(expr) {
363 self.found = Some(expr.clone());
364 return;
365 }
366 visit::visit_expr(self, expr);
367 }
368 }
369 let mut f = Finder { found: None };
370 f.visit_block(block);
371 f.found
372}
373
374fn is_jsonuiview_chain(expr: &Expr) -> bool {
377 match expr {
378 Expr::Call(call) => {
379 path_starts_with(&call.func, "JsonUiView")
380 && path_ident_tail(&call.func).as_deref() == Some("new")
381 }
382 Expr::MethodCall(mc) => is_jsonuiview_chain(&mc.receiver),
383 _ => false,
384 }
385}
386
387fn path_starts_with(expr: &Expr, head: &str) -> bool {
388 if let Expr::Path(p) = expr {
389 if let Some(first) = p.path.segments.first() {
390 return first.ident == head;
391 }
392 }
393 false
394}
395
396fn path_ident_tail(expr: &Expr) -> Option<String> {
397 if let Expr::Path(p) = expr {
398 return p.path.segments.last().map(|s| s.ident.to_string());
399 }
400 None
401}
402
403fn extract_chain(expr: &Expr) -> Option<ExtractedView> {
405 let mut view = ExtractedView::default();
406 let mut cursor = expr;
407 loop {
408 match cursor {
409 Expr::MethodCall(mc) => {
410 let method = mc.method.to_string();
411 if method == "layout" {
412 let arg = mc.args.first()?;
413 view.layout = Some(lit_str(arg)?);
414 }
415 cursor = &mc.receiver;
418 }
419 Expr::Call(call) => {
420 if !is_jsonuiview_chain(cursor) {
421 return None;
422 }
423 let mut args = call.args.iter();
424 let title_expr = args.next()?;
425 view.title = Some(lit_str(title_expr)?);
426 let nodes_expr = args.next()?;
427 view.nodes = parse_node_list(nodes_expr)?;
428 return Some(view);
429 }
430 _ => return None,
431 }
432 }
433}
434
435fn parse_node_list(expr: &Expr) -> Option<Vec<ExtractedNode>> {
439 let Expr::Macro(m) = expr else {
440 return None;
441 };
442 if m.mac.path.segments.last().map(|s| s.ident.to_string()) != Some("vec".to_string()) {
443 return None;
444 }
445 let parser = syn::punctuated::Punctuated::<Expr, syn::Token![,]>::parse_terminated;
446 let exprs = parser.parse2(m.mac.tokens.clone()).ok()?;
447 exprs.iter().map(parse_make_node).collect()
448}
449
450fn parse_make_node(expr: &Expr) -> Option<ExtractedNode> {
451 let Expr::Call(call) = expr else { return None };
452 let fn_name = path_ident_tail(&call.func)?;
453 let with_action = match fn_name.as_str() {
454 "make_node" => false,
455 "make_node_with_action" => true,
456 _ => return None,
457 };
458 let mut args = call.args.iter();
459 let id_arg = args.next()?;
460 let id = lit_str(id_arg)?;
461 let component_expr = args.next()?;
462 let (component_type, props_map, nested_children) = parse_component_expr(component_expr)?;
463 let action = if with_action {
464 let action_expr = args.next()?;
465 Some(parse_action_expr(action_expr)?)
466 } else {
467 None
468 };
469 Some(ExtractedNode {
470 id,
471 component_type,
472 props: props_map,
473 children_nodes: nested_children,
474 action,
475 })
476}
477
478fn parse_component_expr(expr: &Expr) -> Option<(String, Map<String, Value>, Vec<ExtractedNode>)> {
483 let Expr::Call(call) = expr else { return None };
484 let component_type = path_ident_tail(&call.func)?;
485 let arg = call.args.first()?;
486 let Expr::Struct(struct_expr) = arg else {
487 return None;
488 };
489 let mut props = Map::new();
490 let mut nested_children: Vec<ExtractedNode> = Vec::new();
491 for field in &struct_expr.fields {
492 let field_name = match &field.member {
493 syn::Member::Named(ident) => ident.to_string(),
494 syn::Member::Unnamed(_) => return None,
495 };
496 if matches!(field_name.as_str(), "fields" | "buttons" | "children") {
498 if let Some(children) = parse_node_list(&field.expr) {
499 nested_children.extend(children);
500 continue;
501 }
502 return None;
503 }
504 if let Some(value) = expr_to_json(&field.expr) {
505 props.insert(field_name, value);
506 } else {
507 return None;
508 }
509 }
510 Some((component_type, props, nested_children))
511}
512
513fn parse_action_expr(expr: &Expr) -> Option<Value> {
516 let Expr::Call(call) = expr else { return None };
517 if !path_starts_with(&call.func, "Action") {
518 return None;
519 }
520 let method_name = path_ident_tail(&call.func)?;
521 let http_method = match method_name.as_str() {
522 "post" => "POST",
523 "get" => "GET",
524 "put" => "PUT",
525 "patch" => "PATCH",
526 "delete" => "DELETE",
527 _ => return None,
528 };
529 let arg = call.args.first()?;
530 let handler_name = lit_str(arg)?;
531 let mut m = Map::new();
532 m.insert("handler".to_string(), Value::String(handler_name));
533 m.insert("method".to_string(), Value::String(http_method.to_string()));
534 Some(Value::Object(m))
535}
536
537fn expr_to_json(expr: &Expr) -> Option<Value> {
545 if let Expr::MethodCall(mc) = expr {
546 if mc.method == "to_string" || mc.method == "into" {
547 return expr_to_json(&mc.receiver);
548 }
549 }
550 if let Expr::Lit(ExprLit { lit, .. }) = expr {
551 return match lit {
552 Lit::Str(s) => Some(Value::String(s.value())),
553 Lit::Bool(b) => Some(Value::Bool(b.value)),
554 Lit::Int(i) => i.base10_parse::<i64>().ok().map(Value::from),
555 Lit::Float(f) => f
556 .base10_parse::<f64>()
557 .ok()
558 .and_then(serde_json::Number::from_f64)
559 .map(Value::Number),
560 _ => None,
561 };
562 }
563 if let Expr::Call(call) = expr {
564 if path_ident_tail(&call.func).as_deref() == Some("Some") {
565 return expr_to_json(call.args.first()?);
566 }
567 }
568 if let Expr::Path(p) = expr {
569 if p.path.is_ident("None") {
570 return Some(Value::Null);
571 }
572 if p.path.segments.len() >= 2 {
573 let variant = p.path.segments.last().unwrap().ident.to_string();
574 return Some(Value::String(camel_to_snake(&variant)));
575 }
576 }
577 None
578}
579
580fn camel_to_snake(s: &str) -> String {
581 let mut out = String::with_capacity(s.len() + 2);
582 for (i, ch) in s.char_indices() {
583 if ch.is_uppercase() {
584 if i != 0 {
585 out.push('_');
586 }
587 for low in ch.to_lowercase() {
588 out.push(low);
589 }
590 } else {
591 out.push(ch);
592 }
593 }
594 out
595}
596
597fn lit_str(expr: &Expr) -> Option<String> {
600 if let Expr::MethodCall(ExprMethodCall {
601 method, receiver, ..
602 }) = expr
603 {
604 if method == "to_string" || method == "into" {
605 return lit_str(receiver);
606 }
607 }
608 if let Expr::Lit(ExprLit {
609 lit: Lit::Str(s), ..
610 }) = expr
611 {
612 return Some(s.value());
613 }
614 None
615}
616
617fn has_runtime_branch(block: &syn::Block) -> bool {
620 struct Detector(bool);
623 impl<'ast> Visit<'ast> for Detector {
624 fn visit_expr_if(&mut self, expr: &'ast syn::ExprIf) {
625 if contains_jsonuiview_new(&expr.then_branch) {
626 self.0 = true;
627 return;
628 }
629 if let Some((_, else_branch)) = &expr.else_branch {
630 let s = quote::quote!(#else_branch).to_string();
631 if s.contains("JsonUiView") {
632 self.0 = true;
633 return;
634 }
635 }
636 visit::visit_expr_if(self, expr);
637 }
638 fn visit_expr_match(&mut self, expr: &'ast syn::ExprMatch) {
639 for arm in &expr.arms {
640 let s = quote::quote!(#arm).to_string();
641 if s.contains("JsonUiView") {
642 self.0 = true;
643 return;
644 }
645 }
646 visit::visit_expr_match(self, expr);
647 }
648 }
649 let mut d = Detector(false);
650 d.visit_block(block);
651 d.0
652}
653
654fn has_dynamic_key(body_tokens: &str) -> bool {
655 if !body_tokens.contains("format !") {
659 return false;
660 }
661 body_tokens.contains("make_node")
662}
663
664fn contains_jsonuiview_new(block: &syn::Block) -> bool {
665 quote::quote!(#block).to_string().contains("JsonUiView")
666}
667
668fn inject_todo_above_handler(src: &str, handler_name: &str, marker: &str) -> String {
671 let needle = format!("fn {handler_name}(");
674 let Some(fn_pos) = src.find(&needle) else {
675 return src.to_string();
676 };
677 let line_start = src[..fn_pos].rfind('\n').map(|n| n + 1).unwrap_or(0);
678 let mut out = String::with_capacity(src.len() + marker.len());
679 out.push_str(&src[..line_start]);
680 out.push_str(marker);
681 out.push_str(&src[line_start..]);
682 out
683}
684
685fn rewrite_handler_body(src: &str, handler_name: &str, spec_path: &str) -> Result<String> {
686 let needle = format!("fn {handler_name}(");
687 let Some(start) = src.find(&needle) else {
688 eprintln!("warning: handler {handler_name} not found at rewrite time");
689 return Ok(src.to_string());
690 };
691 let Some(brace_off) = src[start..].find('{') else {
692 return Ok(src.to_string());
693 };
694 let body_start = start + brace_off;
695 let mut depth = 0i32;
696 let mut body_end = body_start;
697 for (i, ch) in src[body_start..].char_indices() {
698 match ch {
699 '{' => depth += 1,
700 '}' => {
701 depth -= 1;
702 if depth == 0 {
703 body_end = body_start + i + 1;
704 break;
705 }
706 }
707 _ => {}
708 }
709 }
710 if body_end == body_start {
711 return Ok(src.to_string());
712 }
713 let new_body =
714 format!("{{\n JsonUi::render_file(\"{spec_path}\", serde_json::json!({{}}))\n}}");
715 let mut out = String::with_capacity(src.len());
716 out.push_str(&src[..body_start]);
717 out.push_str(&new_body);
718 out.push_str(&src[body_end..]);
719 Ok(out)
720}