Skip to main content

ferro_cli/commands/
json_ui_migrate_v1.rs

1//! v1 → v2 JSON-UI controller migration codemod.
2//!
3//! Reads a Rust controller file using the v1 `make_node` / `JsonUiView::new`
4//! builder pattern, emits a flat JSON spec under `src/views/{module}/{handler}.json`,
5//! and rewrites the controller body to call `JsonUi::render_file(...)`.
6//!
7//! Phase 163 D-09 / D-10 / D-11:
8//!   - AST-based (`syn` 2), not regex (D-11).
9//!   - Single file per invocation (D-10) — no directory recursion.
10//!   - Idempotent — re-running on already-migrated source emits a warning
11//!     and exits 0 (D-11).
12//!   - `--dry-run` prints proposed output without writing (D-11).
13//!   - Cases the codemod cannot translate produce a `// TODO: …` marker
14//!     above the handler signature; the handler body is left intact (D-09).
15//!
16//! The output spec path is `src/views/{module}/{handler}.json`, where `{module}`
17//! is the file stem of the controller (e.g. `src/controllers/auth.rs` →
18//! `src/views/auth/login_form.json`).
19
20use 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
28/// Entry point invoked by the `json-ui:migrate-v1` subcommand.
29pub 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
80/// Detect "already migrated" via two signals:
81///   - any `JsonUi::render_file(...)` call (AST), or
82///   - any `// TODO: ferro json-ui:migrate-v1 could not auto-translate` marker
83///     in the source text (syn strips line comments from the AST, so we check
84///     the raw source string directly).
85///
86/// The marker check makes the codemod idempotent on files that contain ONLY
87/// un-translatable handlers — without it, every re-run would prepend another
88/// TODO marker line.
89fn 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
108/// Collected output of a migration run.
109struct 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
129/// AST visitor that scans top-level `ItemFn`s and impl-method `ImplItemFn`s
130/// for v1 `JsonUiView::new` / `make_node` patterns.
131struct MigrationVisitor {
132    /// Successful per-handler migrations (one spec per handler).
133    specs: Vec<HandlerMigration>,
134    /// Handlers that could not be migrated; rewritten file gets a TODO comment.
135    todo_handlers: Vec<String>,
136    /// Source path; used to compute the `src/views/{module}` prefix.
137    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        // Inject TODO markers first so subsequent rewrites do not shift the
159        // anchor strings the marker injector matches against.
160        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        // For Plan 07 scope, we only auto-translate free functions.
209        // impl-block methods that mention JsonUiView get a TODO marker.
210        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    // Known un-translatable patterns. Spec::builder fall-through is not a v1
235    // shape we can translate flatly; dynamic-key format!() defeats id stability.
236    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    // Build the flat element map. v2 requires exactly one root; reject both
251    // empty (top_ids.is_empty()) and multi-root (top_ids.len() > 1) handlers
252    // as Unsupported so they pass through the TODO-marker path. WR-01 fix
253    // (Phase 163.1) — see 163-REVIEW.md Option B.
254    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// ---- Extraction types ----
321
322#[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 parsed from `fields: vec![...]` / `buttons: vec![...]` /
335    /// `children: vec![...]` fields nested inside the component's struct
336    /// literal. The caller flattens these into the top-level elements map.
337    children_nodes: Vec<ExtractedNode>,
338    action: Option<Value>,
339}
340
341/// Walk the function block looking for a `JsonUiView::new(title, vec![...])`
342/// call (optionally followed by `.layout("name")` chained method calls).
343/// Returns `None` if the body does not contain such a chain or if any
344/// element of the chain is not a literal we can translate.
345fn extract_view_call(block: &syn::Block) -> Option<ExtractedView> {
346    let chain = find_view_chain(block)?;
347    extract_chain(&chain)
348}
349
350/// Find the outermost `Expr` in the block that is either `JsonUiView::new(...)`
351/// or a method-call chain on top of it. Returns the matched expression cloned
352/// into an owned `Expr` so the visitor's borrow lifetime is bounded.
353fn 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
374/// Returns true if `expr` is `JsonUiView::new(...)` or a method-call chain
375/// (`...layout(...)` etc.) whose innermost receiver is `JsonUiView::new(...)`.
376fn 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
403/// Walk a `JsonUiView::new(...).layout(...)` chain and build an `ExtractedView`.
404fn 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                // Other method calls (e.g. `.attribute(...)`) are ignored for
416                // Plan 07 scope — only `.layout(...)` is recognized.
417                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
435/// Parse a `vec![ make_node(...), make_node_with_action(...), ... ]` macro
436/// into a flat list of ExtractedNode. Each element must be a `make_node` or
437/// `make_node_with_action` call.
438fn 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
478/// Parse `Component::Variant(StructLit { ..fields.. })`. Returns the variant
479/// name, a JSON Map of literal-field props, and a flattened list of child
480/// nodes derived from `fields: vec![...]`, `buttons: vec![...]`, or
481/// `children: vec![...]` props.
482fn 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        // Children-array fields: collect inner make_node calls.
497        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
513/// Parse `Action::post("name")` / `Action::get("name")` etc. into
514/// `{"handler": "name", "method": "POST"}`.
515fn 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
537/// Convert a syn Expr to a JSON value. Supports:
538///   - `"literal".to_string()` (MethodCall) → String
539///   - `"literal"` (str lit) → String
540///   - `Some("literal".to_string())` → unwraps to String
541///   - `None` → Null
542///   - Integer / bool / float literals → corresponding JSON values
543///   - `Enum::Variant` paths (e.g. `InputType::Email`) → snake_case string
544fn 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
597/// Extract a string literal from an Expr, handling `"x".to_string()` /
598/// `"x".into()`.
599fn 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
617// ---- Heuristics for un-translatable patterns ----
618
619fn has_runtime_branch(block: &syn::Block) -> bool {
620    // True if any `if cond { JsonUiView::new(...) } else { JsonUiView::new(...) }`
621    // or `match cond { _ => JsonUiView::new(...) }` appears in the block.
622    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    // Detect `make_node(format!("..", ...), ...)` patterns. The token stream
656    // serialization spaces `!` apart from `format`, so we look for
657    // `format !` near `make_node`.
658    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
668// ---- Source rewriting helpers ----
669
670fn inject_todo_above_handler(src: &str, handler_name: &str, marker: &str) -> String {
671    // Find `fn <handler_name>(` and insert `marker` above the line that
672    // contains the matched `fn`.
673    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}