Skip to main content

lean_ctx/tools/
ctx_refactor.rs

1use lsp_types::{Location, Position};
2use serde_json::Value;
3
4use crate::lsp::client::uri_to_file_path;
5
6pub fn handle(args: &Value, project_root: &str, abs_path: &str) -> String {
7    let action = args
8        .get("action")
9        .and_then(Value::as_str)
10        .unwrap_or("references");
11
12    if matches!(
13        action,
14        "replace_symbol_body" | "insert_before_symbol" | "insert_after_symbol"
15    ) {
16        return handle_symbol_edit(action, args, project_root);
17    }
18
19    if matches!(action, "rename_preview" | "rename_apply") {
20        return handle_rename_refactor(action, args, project_root);
21    }
22
23    if matches!(action, "safe_delete_preview" | "safe_delete_apply") {
24        return handle_safe_delete_refactor(action, args, project_root);
25    }
26
27    if matches!(action, "move_preview" | "move_apply") {
28        return handle_move_refactor(action, args, project_root);
29    }
30
31    if matches!(action, "inline_preview" | "inline_apply") {
32        return handle_inline_refactor(action, args, project_root);
33    }
34
35    if action == "reformat" {
36        return handle_reformat_refactor(args, project_root);
37    }
38
39    let line = args.get("line").and_then(Value::as_u64).unwrap_or(1) as u32;
40    let column = args.get("column").and_then(Value::as_u64).unwrap_or(0) as u32;
41    let scope = args
42        .get("scope")
43        .and_then(Value::as_str)
44        .unwrap_or("project");
45
46    let uri = match crate::lsp::router::open_file(abs_path, project_root) {
47        Ok(u) => u,
48        Err(e) => return format!("ERROR: {e}"),
49    };
50
51    let position = Position::new(line.saturating_sub(1), column);
52
53    match action {
54        "rename" => handle_rename(args, abs_path, project_root, &uri, position),
55        "references" => handle_references(abs_path, project_root, &uri, position, scope),
56        "definition" => handle_definition(abs_path, project_root, &uri, position),
57        "implementations" => handle_implementations(abs_path, project_root, &uri, position, scope),
58        "declaration" => handle_declaration(abs_path, project_root, &uri, position),
59        "type_hierarchy" => handle_type_hierarchy(args, abs_path, project_root, &uri, position),
60        "symbols_overview" => handle_symbols_overview(abs_path, project_root, &uri),
61        "inspections" => handle_inspections(args, abs_path, project_root, &uri),
62        _ => format!(
63            "ERROR: Unknown action '{action}'. Available: rename, references, definition, \
64             implementations, declaration, type_hierarchy, symbols_overview, inspections, \
65             replace_symbol_body, insert_before_symbol, insert_after_symbol, \
66             rename_preview, rename_apply, safe_delete_preview, safe_delete_apply, \
67             move_preview, move_apply, inline_preview, inline_apply, reformat."
68        ),
69    }
70}
71
72fn handle_rename(
73    args: &Value,
74    file_path: &str,
75    project_root: &str,
76    uri: &lsp_types::Uri,
77    position: Position,
78) -> String {
79    let Some(new_name) = args.get("new_name").and_then(Value::as_str) else {
80        return "ERROR: 'new_name' parameter is required for rename.".to_string();
81    };
82
83    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
84        backend.rename(uri, position, new_name)
85    });
86
87    match result {
88        Ok(Some(edit)) => format_workspace_edit(&edit, project_root),
89        Ok(None) => "No rename edits returned by language server.".to_string(),
90        Err(e) => format!("ERROR: {e}"),
91    }
92}
93
94fn handle_references(
95    file_path: &str,
96    project_root: &str,
97    uri: &lsp_types::Uri,
98    position: Position,
99    scope: &str,
100) -> String {
101    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
102        let locs = backend.references(uri, position, scope)?;
103        Ok((locs, backend.last_truncation()))
104    });
105
106    match result {
107        Ok((locations, meta)) => {
108            let mut out = format_locations(&locations, project_root);
109            out.push_str(&truncation_note(locations.len(), meta));
110            out
111        }
112        Err(e) => format!("ERROR: {e}"),
113    }
114}
115
116fn handle_definition(
117    file_path: &str,
118    project_root: &str,
119    uri: &lsp_types::Uri,
120    position: Position,
121) -> String {
122    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
123        backend.definition(uri, position)
124    });
125
126    match result {
127        Ok(resp) => {
128            let locations = match resp {
129                lsp_types::GotoDefinitionResponse::Scalar(loc) => vec![loc],
130                lsp_types::GotoDefinitionResponse::Array(locs) => locs,
131                lsp_types::GotoDefinitionResponse::Link(links) => links
132                    .into_iter()
133                    .map(|l| Location {
134                        uri: l.target_uri,
135                        range: l.target_selection_range,
136                    })
137                    .collect(),
138            };
139            format_locations(&locations, project_root)
140        }
141        Err(e) => format!("ERROR: {e}"),
142    }
143}
144
145fn handle_implementations(
146    file_path: &str,
147    project_root: &str,
148    uri: &lsp_types::Uri,
149    position: Position,
150    scope: &str,
151) -> String {
152    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
153        let locs = backend.implementations(uri, position, scope)?;
154        Ok((locs, backend.last_truncation()))
155    });
156
157    match result {
158        Ok((locations, meta)) => {
159            let mut out = format_locations(&locations, project_root);
160            out.push_str(&truncation_note(locations.len(), meta));
161            out
162        }
163        Err(e) => format!("ERROR: {e}"),
164    }
165}
166
167fn handle_declaration(
168    file_path: &str,
169    project_root: &str,
170    uri: &lsp_types::Uri,
171    position: Position,
172) -> String {
173    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
174        backend.declaration(uri, position)
175    });
176
177    match result {
178        Ok(locations) => format_locations(&locations, project_root),
179        Err(e) => format!("ERROR: {e}"),
180    }
181}
182
183use crate::lsp::backend::{
184    HierarchyDirection, InspectionDiag, InspectionInfo, SymbolOverviewItem, TypeHierarchyNode,
185};
186
187/// A resolved symbol location (project-relative path + 1-based inclusive line span).
188#[derive(Debug)]
189pub(crate) struct Resolved {
190    pub rel_path: String,
191    pub start_line: usize,
192    pub end_line: usize,
193}
194
195/// Apply a resolved edit. IDE-first: a live JetBrains backend (port file +
196/// liveness, mirroring router::select_backend) handles it via WriteCommandAction;
197/// otherwise the headless local_range_write applies the identical bytes.
198pub(crate) fn apply_symbol_edit(
199    action: &str,
200    project_root: &str,
201    edit: &crate::lsp::backend::RangeEdit,
202) -> Result<crate::lsp::backend::EditResult, String> {
203    use crate::lsp::backend::LspBackend;
204    use crate::lsp::port_discovery;
205
206    let mut backend: Box<dyn LspBackend> =
207        if let Some(pf) = port_discovery::read_port_file(project_root) {
208            if port_discovery::pid_alive(pf.pid) && port_discovery::health_ok(&pf) {
209                Box::new(crate::lsp::jetbrains_backend::JetBrainsHttpBackend::new(
210                    pf.port,
211                    pf.token,
212                    project_root.to_string(),
213                    pf.pid,
214                ))
215            } else {
216                Box::new(crate::lsp::edit_apply::HeadlessBackend)
217            }
218        } else {
219            Box::new(crate::lsp::edit_apply::HeadlessBackend)
220        };
221
222    match action {
223        "replace_symbol_body" => backend.replace_symbol_body(edit),
224        "insert_before_symbol" => backend.insert_before_symbol(edit),
225        "insert_after_symbol" => backend.insert_after_symbol(edit),
226        other => Err(format!("INTERNAL: not an edit action: {other}")),
227    }
228}
229
230/// Leading whitespace of the 1-based `line` in `content` (anchor indentation).
231pub(crate) fn anchor_indent(content: &str, line: usize) -> String {
232    content
233        .lines()
234        .nth(line.saturating_sub(1))
235        .map(|l| l.chars().take_while(|c| *c == ' ' || *c == '\t').collect())
236        .unwrap_or_default()
237}
238
239/// Prefix `indent` to the first line of `text` iff that line has no leading
240/// whitespace of its own (deterministic; the same Rust computes it for both
241/// apply paths, so the wire text is byte-identical).
242pub(crate) fn reindent_first_line(text: &str, indent: &str) -> String {
243    if text.starts_with(' ') || text.starts_with('\t') || indent.is_empty() {
244        return text.to_string();
245    }
246    format!("{indent}{text}")
247}
248
249/// Resolve a `name_path` (`Class/method` or bare `name`) to a single symbol via
250/// the tree-sitter index (spec v2a §3/§5.3). Disambiguates a qualified path by
251/// enclosing-range containment (ancestor symbol's line span contains the leaf's).
252pub(crate) fn resolve_name_path(name_path: &str, project_root: &str) -> Result<Resolved, String> {
253    use crate::core::graph_provider;
254    let open = graph_provider::open_or_build(project_root)
255        .ok_or_else(|| "NO_SYMBOL: no symbol index available".to_string())?;
256    let gp = &open.provider;
257
258    let segments: Vec<&str> = name_path.split('/').filter(|s| !s.is_empty()).collect();
259    let leaf = *segments
260        .last()
261        .ok_or_else(|| "NO_SYMBOL: empty name_path".to_string())?;
262
263    // Exact-name leaf candidates (case-sensitive — the index may substring-match).
264    let mut leaves: Vec<_> = gp
265        .find_symbols(leaf, None, None)
266        .into_iter()
267        .filter(|s| s.name == leaf)
268        .collect();
269
270    // Qualify by the immediate ancestor segment, if present.
271    if segments.len() >= 2 {
272        let ancestor = segments[segments.len() - 2];
273        let parents: Vec<_> = gp
274            .find_symbols(ancestor, None, None)
275            .into_iter()
276            .filter(|s| s.name == ancestor)
277            .collect();
278        leaves.retain(|leaf_sym| {
279            parents.iter().any(|p| {
280                p.file == leaf_sym.file
281                    && p.start_line <= leaf_sym.start_line
282                    && leaf_sym.end_line <= p.end_line
283            })
284        });
285    }
286
287    match leaves.len() {
288        0 => Err(format!(
289            "NO_SYMBOL: '{name_path}' did not resolve to any indexed symbol"
290        )),
291        1 => Ok(Resolved {
292            rel_path: leaves[0].file.clone(),
293            start_line: leaves[0].start_line,
294            end_line: leaves[0].end_line,
295        }),
296        _ => {
297            let mut msg = format!(
298                "AMBIGUOUS_SYMBOL: '{name_path}' matches {} symbols; qualify it:\n",
299                leaves.len()
300            );
301            for s in leaves.iter().take(10) {
302                msg.push_str(&format!(
303                    "  {}:{} (L{}-{})\n",
304                    s.file, s.name, s.start_line, s.end_line
305                ));
306            }
307            Err(msg)
308        }
309    }
310}
311
312/// Read the current on-disk text covered by a usage's range, jail-checking its
313/// path first. Out-of-jail / unreadable / bad range → `Err` (spec §5.4 Multi-File
314/// jail: every plugin-reported path is re-checked against `project_root`).
315pub(crate) fn usage_range_text(
316    project_root: &str,
317    u: &crate::lsp::backend::UsageSite,
318) -> Result<String, String> {
319    let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &u.path)
320        .map_err(|e| format!("CONFLICT: usage path blocked by jail: {e}"))?;
321    let content =
322        std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
323    let s = crate::lsp::edit_apply::offset_of(&content, u.range.start_line, u.range.start_char)?;
324    let e = crate::lsp::edit_apply::offset_of(&content, u.range.end_line, u.range.end_char)?;
325    if e < s {
326        return Err("POSITION_OUT_OF_RANGE: end before start".to_string());
327    }
328    Ok(content[s..e].to_string())
329}
330
331/// Stateless Multi-File integrity guard (spec §5.2). BLAKE3 over the usages
332/// canonicalized by sorted `(path, range)` plus each usage's *current* on-disk
333/// text. `context` is display-only and intentionally excluded. Re-built in
334/// `rename_apply` and compared → mismatch = `CONFLICT` (TOCTOU).
335pub(crate) fn plan_hash(
336    project_root: &str,
337    usages: &[crate::lsp::backend::UsageSite],
338) -> Result<String, String> {
339    use crate::lsp::backend::TextRange0Based;
340    let mut rows: Vec<(String, TextRange0Based, String)> = Vec::with_capacity(usages.len());
341    for u in usages {
342        let text = usage_range_text(project_root, u)?;
343        rows.push((u.path.clone(), u.range, text));
344    }
345    rows.sort_by(|a, b| {
346        a.0.cmp(&b.0)
347            .then(a.1.start_line.cmp(&b.1.start_line))
348            .then(a.1.start_char.cmp(&b.1.start_char))
349            .then(a.1.end_line.cmp(&b.1.end_line))
350            .then(a.1.end_char.cmp(&b.1.end_char))
351    });
352    let mut canon = String::new();
353    for (path, r, text) in &rows {
354        canon.push_str(&format!(
355            "{path}|{}:{}-{}:{}|{text}\n",
356            r.start_line, r.start_char, r.end_line, r.end_char
357        ));
358    }
359    Ok(crate::core::hasher::hash_hex(canon.as_bytes()))
360}
361
362/// Resolve the rename target: `name_path` (primary, reuse v2a) or `path`+`line`
363/// (+`end_line`) fallback. Returns `(rel_path, start_line, end_line)` 1-based incl.
364fn resolve_rename_target(
365    args: &Value,
366    project_root: &str,
367) -> Result<(String, usize, usize), String> {
368    if let Some(np) = args.get("name_path").and_then(Value::as_str) {
369        let r = resolve_name_path(np, project_root)?;
370        Ok((r.rel_path, r.start_line, r.end_line))
371    } else {
372        let path = args
373            .get("path")
374            .and_then(Value::as_str)
375            .ok_or_else(|| "provide 'name_path' or 'path'+'line' for rename.".to_string())?;
376        let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
377        let end = args
378            .get("end_line")
379            .and_then(Value::as_u64)
380            .unwrap_or(line as u64) as usize;
381        if line == 0 {
382            return Err("'line' is required (1-based) when using the path fallback.".to_string());
383        }
384        Ok((path.to_string(), line, end))
385    }
386}
387
388/// Deterministic 3-stage Backing-B reachability gate (spec §3.1, v1-§8): live
389/// port file + pid alive + `/health` ping. Any miss → `BACKEND_REQUIRED` BEFORE
390/// any rename HTTP call. NO fallback to Backing A (no IDE-grade rename there).
391fn live_jetbrains_backend(
392    project_root: &str,
393) -> Result<Box<dyn crate::lsp::backend::LspBackend>, String> {
394    use crate::lsp::port_discovery;
395    if let Some(pf) = port_discovery::read_port_file(project_root) {
396        if port_discovery::pid_alive(pf.pid) && port_discovery::health_ok(&pf) {
397            return Ok(Box::new(
398                crate::lsp::jetbrains_backend::JetBrainsHttpBackend::new(
399                    pf.port,
400                    pf.token,
401                    project_root.to_string(),
402                    pf.pid,
403                ),
404            ));
405        }
406    }
407    Err("BACKEND_REQUIRED: rename requires a running JetBrains IDE \
408         (no live port file / health check failed)"
409        .to_string())
410}
411
412/// Phase 1 renderer: ask Backing B for usages+conflicts, build the stateless
413/// plan_hash, and present the blast radius (files, usage count, conflicts).
414fn render_rename_preview(
415    backend: &mut dyn crate::lsp::backend::LspBackend,
416    project_root: &str,
417    query: &crate::lsp::backend::RenameQuery,
418    new_name: &str,
419) -> String {
420    let plan = match backend.rename_preview(query) {
421        Ok(p) => p,
422        Err(e) => return format!("ERROR: {e}"),
423    };
424    let hash = match plan_hash(project_root, &plan.usages) {
425        Ok(h) => h,
426        Err(e) => return format!("ERROR: {e}"),
427    };
428    let mut usage_files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
429    usage_files.sort_unstable();
430    usage_files.dedup();
431    let mut all_files: Vec<&str> = usage_files.clone();
432    all_files.push(query.rel_path.as_str());
433    all_files.sort_unstable();
434    all_files.dedup();
435    let mut out = format!(
436        "rename_preview: '{}' → '{new_name}'\n  usages: {}\n  files: {}\n  plan_hash: {hash}\n",
437        query.rel_path,
438        plan.usages.len(),
439        all_files.len(),
440    );
441    if !plan.conflicts.is_empty() {
442        out.push_str(&format!(
443            "  conflicts: {} (rename_apply blocks unless force=true)\n",
444            plan.conflicts.len()
445        ));
446        for c in &plan.conflicts {
447            out.push_str(&format!("    {}: {}\n", c.path, c.message));
448        }
449    }
450    for f in &usage_files {
451        let n = plan.usages.iter().filter(|u| u.path == **f).count();
452        out.push_str(&format!("  {f}: {n} usage(s)\n"));
453    }
454    out
455}
456
457/// Phase 2 renderer: re-fetch usages, enforce the plan_hash (TOCTOU) + conflict
458/// gates in Rust, then run the IDE Multi-File transaction and evict changed files.
459fn render_rename_apply(
460    backend: &mut dyn crate::lsp::backend::LspBackend,
461    project_root: &str,
462    query: &crate::lsp::backend::RenameQuery,
463    new_name: &str,
464    expected_hash: &str,
465    force: bool,
466) -> String {
467    let plan = match backend.rename_preview(query) {
468        Ok(p) => p,
469        Err(e) => return format!("ERROR: {e}"),
470    };
471    // Capture pre-apply usage text (also jail-checks every usage path).
472    let mut pre: Vec<(String, u32, String)> = Vec::with_capacity(plan.usages.len());
473    for u in &plan.usages {
474        match usage_range_text(project_root, u) {
475            Ok(t) => pre.push((u.path.clone(), u.range.start_line + 1, t)),
476            Err(e) => return format!("ERROR: {e}"),
477        }
478    }
479    // Gate (a): TOCTOU plan_hash.
480    let actual = match plan_hash(project_root, &plan.usages) {
481        Ok(h) => h,
482        Err(e) => return format!("ERROR: {e}"),
483    };
484    if actual != expected_hash {
485        return format!(
486            "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
487             expected={expected_hash}, actual={actual})"
488        );
489    }
490    // Gate (b): refactoring conflicts.
491    if !plan.conflicts.is_empty() && !force {
492        return format!(
493            "ERROR: CONFLICT: {} refactoring conflict(s); pass force=true to override",
494            plan.conflicts.len()
495        );
496    }
497
498    let apply = crate::lsp::backend::RenameApply {
499        abs_path: query.abs_path.clone(),
500        rel_path: query.rel_path.clone(),
501        target_range: query.target_range,
502        new_name: new_name.to_string(),
503        force,
504    };
505    let res = match backend.rename_apply(&apply) {
506        Ok(r) => r,
507        Err(e) => return format!("ERROR: {e}"),
508    };
509
510    // Jail-check + cache-evict each changed file (Multi-File coherence, spec §9).
511    for cp in &res.changed_paths {
512        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
513            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
514            Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
515        }
516    }
517
518    let mut out = format!(
519        "rename_apply: '{}' → '{new_name}' applied\n  changed files: {}\n  usages: {}\n",
520        query.rel_path,
521        res.changed_paths.len(),
522        pre.len(),
523    );
524    for (path, line, old) in &pre {
525        out.push_str(&format!("  {path}:{line}  \"{old}\" → \"{new_name}\"\n"));
526    }
527    out
528}
529
530/// Entry for the Two-Phase rename actions. Resolves the target (name_path / pos),
531/// double-jails, requires a live IDE, then dispatches to the preview/apply renderer.
532fn handle_rename_refactor(action: &str, args: &Value, project_root: &str) -> String {
533    let Some(new_name) = args.get("new_name").and_then(Value::as_str) else {
534        return "ERROR: 'new_name' is required for rename.".to_string();
535    };
536    if action == "rename_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
537        return "ERROR: 'plan_hash' is required for rename_apply (run rename_preview first)."
538            .to_string();
539    }
540
541    // Resolve target symbol → 1-based inclusive span.
542    let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
543        Ok(t) => t,
544        Err(e) => return format!("ERROR: {e}"),
545    };
546    // PathJail stage (a): the resolved target path.
547    let abs_path =
548        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
549            Ok(p) => p,
550            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
551        };
552    let content = match std::fs::read_to_string(&abs_path) {
553        Ok(c) => c,
554        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
555    };
556    let end_col = content
557        .lines()
558        .nth(end_line.saturating_sub(1))
559        .map_or(0, str::len) as u32;
560    let target_range = crate::lsp::backend::TextRange0Based {
561        start_line: (start_line - 1) as u32,
562        start_char: 0,
563        end_line: (end_line - 1) as u32,
564        end_char: end_col,
565    };
566    let search_comments = args
567        .get("search_comments")
568        .and_then(Value::as_bool)
569        .unwrap_or(false);
570    let search_text_occurrences = args
571        .get("search_text_occurrences")
572        .and_then(Value::as_bool)
573        .unwrap_or(false);
574
575    // Backing B is mandatory (no headless rename) → BACKEND_REQUIRED otherwise.
576    let mut backend = match live_jetbrains_backend(project_root) {
577        Ok(b) => b,
578        Err(e) => return format!("ERROR: {e}"),
579    };
580
581    let query = crate::lsp::backend::RenameQuery {
582        abs_path,
583        rel_path,
584        target_range,
585        new_name: new_name.to_string(),
586        search_comments,
587        search_text_occurrences,
588    };
589
590    match action {
591        "rename_preview" => render_rename_preview(backend.as_mut(), project_root, &query, new_name),
592        "rename_apply" => {
593            let expected = args
594                .get("plan_hash")
595                .and_then(Value::as_str)
596                .unwrap_or_default();
597            let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
598            render_rename_apply(
599                backend.as_mut(),
600                project_root,
601                &query,
602                new_name,
603                expected,
604                force,
605            )
606        }
607        other => format!("ERROR: INTERNAL: not a rename action: {other}"),
608    }
609}
610
611/// Phase 1 renderer for safe_delete: ask Backing B for the REMAINING references
612/// (blocking usages/conflicts), build the stateless plan_hash, present them.
613fn render_safe_delete_preview(
614    backend: &mut dyn crate::lsp::backend::LspBackend,
615    project_root: &str,
616    query: &crate::lsp::backend::SafeDeleteQuery,
617) -> String {
618    let plan = match backend.safe_delete_preview(query) {
619        Ok(p) => p,
620        Err(e) => return format!("ERROR: {e}"),
621    };
622    let hash = match plan_hash(project_root, &plan.usages) {
623        Ok(h) => h,
624        Err(e) => return format!("ERROR: {e}"),
625    };
626    let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
627    files.sort_unstable();
628    files.dedup();
629    let mut out = format!(
630        "safe_delete_preview: '{}'\n  blocking usages: {}\n  files: {}\n  plan_hash: {hash}\n",
631        query.rel_path,
632        plan.usages.len(),
633        files.len(),
634    );
635    if !plan.conflicts.is_empty() {
636        out.push_str(&format!(
637            "  conflicts: {} (safe_delete_apply blocks unless force=true)\n",
638            plan.conflicts.len()
639        ));
640        for c in &plan.conflicts {
641            out.push_str(&format!("    {}: {}\n", c.path, c.message));
642        }
643    }
644    for f in &files {
645        let n = plan.usages.iter().filter(|u| u.path == **f).count();
646        out.push_str(&format!("  {f}: {n} remaining ref(s)\n"));
647    }
648    out
649}
650
651/// Phase 2 renderer for safe_delete: re-fetch usages, enforce plan_hash (TOCTOU)
652/// and a conflict gate (conflict = "reference still exists", spec §5.4) in Rust,
653/// then run the IDE delete transaction and evict changed files.
654fn render_safe_delete_apply(
655    backend: &mut dyn crate::lsp::backend::LspBackend,
656    project_root: &str,
657    query: &crate::lsp::backend::SafeDeleteQuery,
658    expected_hash: &str,
659    force: bool,
660    propagate: bool,
661) -> String {
662    let plan = match backend.safe_delete_preview(query) {
663        Ok(p) => p,
664        Err(e) => return format!("ERROR: {e}"),
665    };
666    // Gate (a): TOCTOU plan_hash (also jail-checks every usage path).
667    let actual = match plan_hash(project_root, &plan.usages) {
668        Ok(h) => h,
669        Err(e) => return format!("ERROR: {e}"),
670    };
671    if actual != expected_hash {
672        return format!(
673            "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
674             expected={expected_hash}, actual={actual})"
675        );
676    }
677    // Gate (b): remaining references block unless force.
678    if !plan.conflicts.is_empty() && !force {
679        return format!(
680            "ERROR: CONFLICT: {} blocking reference(s) remain; pass force=true to delete anyway",
681            plan.conflicts.len()
682        );
683    }
684
685    let apply = crate::lsp::backend::SafeDeleteApply {
686        query: query.clone(),
687        force,
688        propagate,
689    };
690    let res = match backend.safe_delete_apply(&apply) {
691        Ok(r) => r,
692        Err(e) => return format!("ERROR: {e}"),
693    };
694
695    // Jail-check + cache-evict each changed file (Multi-File coherence, spec §9).
696    for cp in &res.changed_paths {
697        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
698            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
699            Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
700        }
701    }
702
703    format!(
704        "safe_delete_apply: '{}' deleted\n  changed files: {}\n",
705        query.rel_path,
706        res.changed_paths.len(),
707    )
708}
709
710/// Entry for the Two-Phase safe_delete actions. Resolves the source (name_path /
711/// position), jail-checks it, requires a live IDE, then dispatches to the renderer.
712/// Two-stage jail only (source + changed_paths) — no new caller-supplied target.
713fn handle_safe_delete_refactor(action: &str, args: &Value, project_root: &str) -> String {
714    if action == "safe_delete_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
715        return "ERROR: 'plan_hash' is required for safe_delete_apply (run safe_delete_preview first)."
716            .to_string();
717    }
718    // Resolve source symbol → 1-based inclusive span (reuse v2b resolver).
719    let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
720        Ok(t) => t,
721        Err(e) => return format!("ERROR: {e}"),
722    };
723    let abs_path =
724        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
725            Ok(p) => p,
726            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
727        };
728    let content = match std::fs::read_to_string(&abs_path) {
729        Ok(c) => c,
730        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
731    };
732    let end_col = content
733        .lines()
734        .nth(end_line.saturating_sub(1))
735        .map_or(0, str::len) as u32;
736    let src_range = crate::lsp::backend::TextRange0Based {
737        start_line: (start_line - 1) as u32,
738        start_char: 0,
739        end_line: (end_line - 1) as u32,
740        end_char: end_col,
741    };
742
743    let mut backend = match live_jetbrains_backend(project_root) {
744        Ok(b) => b,
745        Err(e) => return format!("ERROR: {e}"),
746    };
747
748    let query = crate::lsp::backend::SafeDeleteQuery {
749        abs_path,
750        rel_path,
751        src_range,
752    };
753
754    match action {
755        "safe_delete_preview" => render_safe_delete_preview(backend.as_mut(), project_root, &query),
756        "safe_delete_apply" => {
757            let expected = args
758                .get("plan_hash")
759                .and_then(Value::as_str)
760                .unwrap_or_default();
761            let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
762            let propagate = args
763                .get("propagate")
764                .and_then(Value::as_bool)
765                .unwrap_or(false);
766            render_safe_delete_apply(
767                backend.as_mut(),
768                project_root,
769                &query,
770                expected,
771                force,
772                propagate,
773            )
774        }
775        other => format!("ERROR: INTERNAL: not a safe_delete action: {other}"),
776    }
777}
778
779/// Resolve the `move` target (spec §5.3 stage 2): EXACTLY ONE of `target_path` /
780/// `target_parent` must be set. `target_path` → jail-checked dir/file →
781/// MoveTarget::Path. `target_parent` → resolve_name_path → its file → MoveTarget::
782/// Parent. None/both → INVALID_TARGET. Jail violation → INVALID_TARGET. This runs
783/// BEFORE any backend call so an out-of-jail target can never reach the plugin.
784fn resolve_move_target(
785    args: &Value,
786    project_root: &str,
787) -> Result<crate::lsp::backend::MoveTarget, String> {
788    let target_path = args.get("target_path").and_then(Value::as_str);
789    let target_parent = args.get("target_parent").and_then(Value::as_str);
790    match (target_path, target_parent) {
791        (Some(_), Some(_)) | (None, None) => {
792            Err("INVALID_TARGET: set exactly one of 'target_path' or 'target_parent'".to_string())
793        }
794        (Some(tp), None) => {
795            let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, tp)
796                .map_err(|e| format!("INVALID_TARGET: target_path blocked by jail: {e}"))?;
797            Ok(crate::lsp::backend::MoveTarget::Path {
798                abs_path: abs,
799                rel_path: tp.to_string(),
800            })
801        }
802        (None, Some(parent_np)) => {
803            // Resolve the parent symbol → its file + declaration span.
804            let r = resolve_name_path(parent_np, project_root)?; // NO_SYMBOL / AMBIGUOUS_SYMBOL
805            let abs =
806                crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &r.rel_path)
807                    .map_err(|e| {
808                        format!("INVALID_TARGET: target_parent file blocked by jail: {e}")
809                    })?;
810            // Read the parent file to compute the end-of-line column (mirror handle_rename_refactor).
811            let content =
812                std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
813            let end_col = content
814                .lines()
815                .nth(r.end_line.saturating_sub(1))
816                .map_or(0, str::len) as u32;
817            Ok(crate::lsp::backend::MoveTarget::Parent {
818                abs_path: abs,
819                rel_path: r.rel_path,
820                range: crate::lsp::backend::TextRange0Based {
821                    start_line: (r.start_line - 1) as u32,
822                    start_char: 0,
823                    end_line: (r.end_line - 1) as u32,
824                    end_char: end_col,
825                },
826            })
827        }
828    }
829}
830
831/// Phase 1 renderer for move: ask Backing B for usages+conflicts at the new
832/// location, build the stateless plan_hash, present the blast radius.
833fn render_move_preview(
834    backend: &mut dyn crate::lsp::backend::LspBackend,
835    project_root: &str,
836    query: &crate::lsp::backend::MoveQuery,
837) -> String {
838    let plan = match backend.move_preview(query) {
839        Ok(p) => p,
840        Err(e) => return format!("ERROR: {e}"),
841    };
842    let hash = match plan_hash(project_root, &plan.usages) {
843        Ok(h) => h,
844        Err(e) => return format!("ERROR: {e}"),
845    };
846    let target_desc = match &query.target {
847        crate::lsp::backend::MoveTarget::Path { rel_path, .. } => format!("→ {rel_path}"),
848        crate::lsp::backend::MoveTarget::Parent { rel_path, .. } => {
849            format!("→ member of {rel_path}")
850        }
851    };
852    let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
853    files.push(query.rel_path.as_str());
854    files.sort_unstable();
855    files.dedup();
856    let mut out = format!(
857        "move_preview: '{}' {target_desc}\n  usages: {}\n  files: {}\n  plan_hash: {hash}\n",
858        query.rel_path,
859        plan.usages.len(),
860        files.len(),
861    );
862    if !plan.conflicts.is_empty() {
863        out.push_str(&format!(
864            "  conflicts: {} (move_apply blocks unless force=true)\n",
865            plan.conflicts.len()
866        ));
867        for c in &plan.conflicts {
868            out.push_str(&format!("    {}: {}\n", c.path, c.message));
869        }
870    }
871    out
872}
873
874/// Phase 2 renderer for move: re-fetch usages, enforce plan_hash (TOCTOU) +
875/// conflict gate in Rust, run the IDE Multi-File move, then jail-check + evict
876/// every changed path (spec §5.3 stage 3 — includes the NEW destination file).
877fn render_move_apply(
878    backend: &mut dyn crate::lsp::backend::LspBackend,
879    project_root: &str,
880    query: &crate::lsp::backend::MoveQuery,
881    expected_hash: &str,
882    force: bool,
883) -> String {
884    let plan = match backend.move_preview(query) {
885        Ok(p) => p,
886        Err(e) => return format!("ERROR: {e}"),
887    };
888    let actual = match plan_hash(project_root, &plan.usages) {
889        Ok(h) => h,
890        Err(e) => return format!("ERROR: {e}"),
891    };
892    if actual != expected_hash {
893        return format!(
894            "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
895             expected={expected_hash}, actual={actual})"
896        );
897    }
898    if !plan.conflicts.is_empty() && !force {
899        return format!(
900            "ERROR: CONFLICT: {} refactoring conflict(s); pass force=true to override",
901            plan.conflicts.len()
902        );
903    }
904
905    let apply = crate::lsp::backend::MoveApply {
906        query: query.clone(),
907        force,
908    };
909    let res = match backend.move_apply(&apply) {
910        Ok(r) => r,
911        Err(e) => return format!("ERROR: {e}"),
912    };
913
914    // Stage-3 jail: every changed path (incl. the new destination file) re-checked
915    // against project_root BEFORE eviction (spec §5.3).
916    for cp in &res.changed_paths {
917        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
918            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
919            Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
920        }
921    }
922
923    format!(
924        "move_apply: '{}' applied\n  changed files: {}\n",
925        query.rel_path,
926        res.changed_paths.len(),
927    )
928}
929
930/// Entry for the Two-Phase move actions. Resolves the source (stage-1 jail), the
931/// target (stage-2 jail via resolve_move_target → INVALID_TARGET on miss/escape),
932/// requires a live IDE, then dispatches. Stage-3 jail is inside render_move_apply.
933fn handle_move_refactor(action: &str, args: &Value, project_root: &str) -> String {
934    if action == "move_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
935        return "ERROR: 'plan_hash' is required for move_apply (run move_preview first)."
936            .to_string();
937    }
938    // Stage 2 (target) BEFORE any read/backend work, so INVALID_TARGET fires first.
939    let target = match resolve_move_target(args, project_root) {
940        Ok(t) => t,
941        Err(e) => return format!("ERROR: {e}"),
942    };
943    // Stage 1 (source).
944    let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
945        Ok(t) => t,
946        Err(e) => return format!("ERROR: {e}"),
947    };
948    let abs_path =
949        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
950            Ok(p) => p,
951            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
952        };
953    let content = match std::fs::read_to_string(&abs_path) {
954        Ok(c) => c,
955        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
956    };
957    let end_col = content
958        .lines()
959        .nth(end_line.saturating_sub(1))
960        .map_or(0, str::len) as u32;
961    let src_range = crate::lsp::backend::TextRange0Based {
962        start_line: (start_line - 1) as u32,
963        start_char: 0,
964        end_line: (end_line - 1) as u32,
965        end_char: end_col,
966    };
967
968    let mut backend = match live_jetbrains_backend(project_root) {
969        Ok(b) => b,
970        Err(e) => return format!("ERROR: {e}"),
971    };
972
973    let query = crate::lsp::backend::MoveQuery {
974        abs_path,
975        rel_path,
976        src_range,
977        target,
978    };
979
980    match action {
981        "move_preview" => render_move_preview(backend.as_mut(), project_root, &query),
982        "move_apply" => {
983            let expected = args
984                .get("plan_hash")
985                .and_then(Value::as_str)
986                .unwrap_or_default();
987            let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
988            render_move_apply(backend.as_mut(), project_root, &query, expected, force)
989        }
990        other => format!("ERROR: INTERNAL: not a move action: {other}"),
991    }
992}
993
994/// Phase 1 renderer for inline: ask Backing B for substitution sites + conflicts,
995/// build the stateless plan_hash, present the blast radius.
996fn render_inline_preview(
997    backend: &mut dyn crate::lsp::backend::LspBackend,
998    project_root: &str,
999    query: &crate::lsp::backend::InlineQuery,
1000) -> String {
1001    let plan = match backend.inline_preview(query) {
1002        Ok(p) => p,
1003        Err(e) => return format!("ERROR: {e}"),
1004    };
1005    let hash = match plan_hash(project_root, &plan.usages) {
1006        Ok(h) => h,
1007        Err(e) => return format!("ERROR: {e}"),
1008    };
1009    let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
1010    files.push(query.rel_path.as_str());
1011    files.sort_unstable();
1012    files.dedup();
1013    let mut out = format!(
1014        "inline_preview: '{}'\n  usages: {}\n  files: {}\n  plan_hash: {hash}\n",
1015        query.rel_path,
1016        plan.usages.len(),
1017        files.len(),
1018    );
1019    if !plan.conflicts.is_empty() {
1020        out.push_str(&format!(
1021            "  conflicts: {} (inline_apply blocks — no force; hard refusal → UNSUPPORTED)\n",
1022            plan.conflicts.len()
1023        ));
1024        for c in &plan.conflicts {
1025            out.push_str(&format!("    {}: {}\n", c.path, c.message));
1026        }
1027    }
1028    out
1029}
1030
1031/// Phase 2 renderer for inline: re-fetch sites, enforce plan_hash (TOCTOU) and a
1032/// FORCE-LESS conflict gate (spec §5.2, Entscheidung 4) in Rust, run the IDE
1033/// inline transaction, then jail-check + evict every changed path.
1034fn render_inline_apply(
1035    backend: &mut dyn crate::lsp::backend::LspBackend,
1036    project_root: &str,
1037    query: &crate::lsp::backend::InlineQuery,
1038    expected_hash: &str,
1039) -> String {
1040    let plan = match backend.inline_preview(query) {
1041        Ok(p) => p,
1042        Err(e) => return format!("ERROR: {e}"),
1043    };
1044    let actual = match plan_hash(project_root, &plan.usages) {
1045        Ok(h) => h,
1046        Err(e) => return format!("ERROR: {e}"),
1047    };
1048    if actual != expected_hash {
1049        return format!(
1050            "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
1051             expected={expected_hash}, actual={actual})"
1052        );
1053    }
1054    // FORCE-LESS gate: any conflict is final (no bypass arg exists, spec §5.2).
1055    if !plan.conflicts.is_empty() {
1056        return format!(
1057            "ERROR: CONFLICT: {} inline conflict(s); inline cannot be forced",
1058            plan.conflicts.len()
1059        );
1060    }
1061
1062    let apply = crate::lsp::backend::InlineApply {
1063        query: query.clone(),
1064    };
1065    let res = match backend.inline_apply(&apply) {
1066        Ok(r) => r,
1067        // Hard refusal from IntelliJ (recursive, multiple returns, override) → UNSUPPORTED.
1068        Err(e) => return format!("ERROR: {e}"),
1069    };
1070
1071    for cp in &res.changed_paths {
1072        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
1073            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
1074            Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
1075        }
1076    }
1077
1078    format!(
1079        "inline_apply: '{}' applied\n  changed files: {}\n",
1080        query.rel_path,
1081        res.changed_paths.len(),
1082    )
1083}
1084
1085/// Entry for the Two-Phase inline actions. Resolves the source (name_path /
1086/// position), jail-checks it, requires a live IDE, then dispatches. NO `force`.
1087fn handle_inline_refactor(action: &str, args: &Value, project_root: &str) -> String {
1088    if action == "inline_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
1089        return "ERROR: 'plan_hash' is required for inline_apply (run inline_preview first)."
1090            .to_string();
1091    }
1092    let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
1093        Ok(t) => t,
1094        Err(e) => return format!("ERROR: {e}"),
1095    };
1096    let abs_path =
1097        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
1098            Ok(p) => p,
1099            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
1100        };
1101    let content = match std::fs::read_to_string(&abs_path) {
1102        Ok(c) => c,
1103        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
1104    };
1105    let end_col = content
1106        .lines()
1107        .nth(end_line.saturating_sub(1))
1108        .map_or(0, str::len) as u32;
1109    let src_range = crate::lsp::backend::TextRange0Based {
1110        start_line: (start_line - 1) as u32,
1111        start_char: 0,
1112        end_line: (end_line - 1) as u32,
1113        end_char: end_col,
1114    };
1115
1116    let mut backend = match live_jetbrains_backend(project_root) {
1117        Ok(b) => b,
1118        Err(e) => return format!("ERROR: {e}"),
1119    };
1120
1121    let keep_definition = args
1122        .get("keep_definition")
1123        .and_then(Value::as_bool)
1124        .unwrap_or(false);
1125    let query = crate::lsp::backend::InlineQuery {
1126        abs_path,
1127        rel_path,
1128        src_range,
1129        keep_definition,
1130    };
1131
1132    match action {
1133        "inline_preview" => render_inline_preview(backend.as_mut(), project_root, &query),
1134        "inline_apply" => {
1135            let expected = args
1136                .get("plan_hash")
1137                .and_then(Value::as_str)
1138                .unwrap_or_default();
1139            render_inline_apply(backend.as_mut(), project_root, &query, expected)
1140        }
1141        other => format!("ERROR: INTERNAL: not an inline action: {other}"),
1142    }
1143}
1144
1145/// Resolve the reformat address (spec §5.3) to (abs_path, rel_path, scope).
1146/// EXACTLY one address form: name_path → Symbol; path alone → File; path+line
1147/// (+end_line) → Region. None / contradictory → INVALID_TARGET. Jail-checked here.
1148fn resolve_reformat_scope(
1149    args: &Value,
1150    project_root: &str,
1151) -> Result<(String, String, crate::lsp::backend::ReformatScope), String> {
1152    use crate::lsp::backend::{ReformatScope, TextRange0Based};
1153    let name_path = args.get("name_path").and_then(Value::as_str);
1154    let path = args.get("path").and_then(Value::as_str);
1155    let line = args.get("line").and_then(Value::as_u64);
1156
1157    match (name_path, path) {
1158        (Some(_), Some(_)) | (None, None) => {
1159            Err("INVALID_TARGET: set exactly one of 'name_path' or 'path' for reformat".to_string())
1160        }
1161        (Some(np), None) => {
1162            let r = resolve_name_path(np, project_root)?; // NO_SYMBOL / AMBIGUOUS_SYMBOL
1163            let abs =
1164                crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &r.rel_path)
1165                    .map_err(|e| format!("INVALID_TARGET: path blocked by jail: {e}"))?;
1166            let content =
1167                std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
1168            let end_col = content
1169                .lines()
1170                .nth(r.end_line.saturating_sub(1))
1171                .map_or(0, str::len) as u32;
1172            let range = TextRange0Based {
1173                start_line: (r.start_line - 1) as u32,
1174                start_char: 0,
1175                end_line: (r.end_line - 1) as u32,
1176                end_char: end_col,
1177            };
1178            Ok((abs, r.rel_path, ReformatScope::Symbol { range }))
1179        }
1180        (None, Some(p)) => {
1181            let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, p)
1182                .map_err(|e| format!("INVALID_TARGET: path blocked by jail: {e}"))?;
1183            match line {
1184                None => Ok((abs, p.to_string(), ReformatScope::File)),
1185                Some(l) => {
1186                    if l == 0 {
1187                        return Err(
1188                            "INVALID_TARGET: 'line' is 1-based (>=1) for a region reformat"
1189                                .to_string(),
1190                        );
1191                    }
1192                    let end = args.get("end_line").and_then(Value::as_u64).unwrap_or(l);
1193                    let content = std::fs::read_to_string(&abs)
1194                        .map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
1195                    let end_col = content
1196                        .lines()
1197                        .nth((end as usize).saturating_sub(1))
1198                        .map_or(0, str::len) as u32;
1199                    let range = TextRange0Based {
1200                        start_line: (l - 1) as u32,
1201                        start_char: 0,
1202                        end_line: (end - 1) as u32,
1203                        end_char: end_col,
1204                    };
1205                    Ok((abs, p.to_string(), ReformatScope::Region { range }))
1206                }
1207            }
1208        }
1209    }
1210}
1211
1212/// Single-Phase reformat: resolve address → scope, jail, require live IDE, run the
1213/// IDE reformat, then Single-File evict (spec §5.3). No plan_hash, no preview.
1214fn render_reformat(
1215    backend: &mut dyn crate::lsp::backend::LspBackend,
1216    project_root: &str,
1217    query: &crate::lsp::backend::ReformatQuery,
1218) -> String {
1219    let res = match backend.reformat(query) {
1220        Ok(r) => r,
1221        Err(e) => return format!("ERROR: {e}"),
1222    };
1223    for cp in &res.changed_paths {
1224        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
1225            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
1226            Err(e) => return format!("ERROR: INVALID_TARGET: changed path blocked by jail: {e}"),
1227        }
1228    }
1229    format!(
1230        "reformat: '{}' applied\n  changed files: {}\n",
1231        query.rel_path,
1232        res.changed_paths.len(),
1233    )
1234}
1235
1236fn handle_reformat_refactor(args: &Value, project_root: &str) -> String {
1237    let (abs_path, rel_path, scope) = match resolve_reformat_scope(args, project_root) {
1238        Ok(t) => t,
1239        Err(e) => return format!("ERROR: {e}"),
1240    };
1241    let mut backend = match live_jetbrains_backend(project_root) {
1242        Ok(b) => b,
1243        Err(e) => return format!("ERROR: {e}"),
1244    };
1245    let optimize_imports = args
1246        .get("optimize_imports")
1247        .and_then(Value::as_bool)
1248        .unwrap_or(false);
1249    let query = crate::lsp::backend::ReformatQuery {
1250        abs_path,
1251        rel_path,
1252        scope,
1253        optimize_imports,
1254    };
1255    render_reformat(backend.as_mut(), project_root, &query)
1256}
1257
1258fn parse_direction(args: &Value) -> HierarchyDirection {
1259    match args.get("direction").and_then(Value::as_str) {
1260        Some("subtypes") => HierarchyDirection::Subtypes,
1261        _ => HierarchyDirection::Supertypes,
1262    }
1263}
1264
1265fn handle_type_hierarchy(
1266    args: &Value,
1267    file_path: &str,
1268    project_root: &str,
1269    uri: &lsp_types::Uri,
1270    position: Position,
1271) -> String {
1272    let direction = parse_direction(args);
1273    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1274        let tree = backend.type_hierarchy(uri, position, direction)?;
1275        Ok((tree, backend.last_truncation()))
1276    });
1277    match result {
1278        Ok((tree, meta)) => {
1279            let mut out = format_type_hierarchy(&tree);
1280            if matches!(meta, Some(m) if m.truncated) {
1281                out.push_str("\n(truncated — depth/node cap reached)\n");
1282            }
1283            out
1284        }
1285        Err(e) => format!("ERROR: {e}"),
1286    }
1287}
1288
1289fn handle_symbols_overview(file_path: &str, project_root: &str, uri: &lsp_types::Uri) -> String {
1290    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1291        let items = backend.symbols_overview(uri)?;
1292        Ok((items, backend.last_truncation()))
1293    });
1294    match result {
1295        Ok((items, meta)) => {
1296            let mut out = format_symbols_overview(&items);
1297            out.push_str(&truncation_note(items.len(), meta));
1298            out
1299        }
1300        Err(e) => format!("ERROR: {e}"),
1301    }
1302}
1303
1304fn handle_symbol_edit(action: &str, args: &Value, project_root: &str) -> String {
1305    // 1) Resolve target: name_path (primary) or path+line(+column) fallback.
1306    let (rel_path, start_line, end_line) = if let Some(np) =
1307        args.get("name_path").and_then(Value::as_str)
1308    {
1309        match resolve_name_path(np, project_root) {
1310            Ok(r) => (r.rel_path, r.start_line, r.end_line),
1311            Err(e) => return format!("ERROR: {e}"),
1312        }
1313    } else {
1314        let Some(path) = args.get("path").and_then(Value::as_str) else {
1315            return "ERROR: provide 'name_path' or 'path'+'line' for symbol edits.".to_string();
1316        };
1317        let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
1318        let end = args
1319            .get("end_line")
1320            .and_then(Value::as_u64)
1321            .unwrap_or(line as u64) as usize;
1322        if line == 0 {
1323            return "ERROR: 'line' is required (1-based) when using the path fallback.".to_string();
1324        }
1325        (path.to_string(), line, end)
1326    };
1327
1328    // 2) PathJail on the resolved path (v1 §4.5 seam — critical before writes).
1329    let abs_path =
1330        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
1331            Ok(p) => p,
1332            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
1333        };
1334
1335    let content = match std::fs::read_to_string(&abs_path) {
1336        Ok(c) => c,
1337        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
1338    };
1339
1340    // 3) Build the canonical range + final wire text per action.
1341    let expected_hash = args
1342        .get("expected_hash")
1343        .and_then(Value::as_str)
1344        .map(String::from);
1345    let (range, text) = match action {
1346        "replace_symbol_body" => {
1347            let Some(new_body) = args.get("new_body").and_then(Value::as_str) else {
1348                return "ERROR: 'new_body' is required for replace_symbol_body.".to_string();
1349            };
1350            let end_col = content
1351                .lines()
1352                .nth(end_line.saturating_sub(1))
1353                .map_or(0, str::len) as u32;
1354            (
1355                crate::lsp::backend::TextRange0Based {
1356                    start_line: (start_line - 1) as u32,
1357                    start_char: 0,
1358                    end_line: (end_line - 1) as u32,
1359                    end_char: end_col,
1360                },
1361                new_body.to_string(),
1362            )
1363        }
1364        "insert_before_symbol" | "insert_after_symbol" => {
1365            let Some(t) = args.get("text").and_then(Value::as_str) else {
1366                return format!("ERROR: 'text' is required for {action}.");
1367            };
1368            let indent = anchor_indent(&content, start_line);
1369            let final_text = format!("{}\n", reindent_first_line(t, &indent));
1370            let insert_line = if action == "insert_before_symbol" {
1371                (start_line - 1) as u32
1372            } else {
1373                end_line as u32
1374            };
1375            (
1376                crate::lsp::backend::TextRange0Based {
1377                    start_line: insert_line,
1378                    start_char: 0,
1379                    end_line: insert_line,
1380                    end_char: 0,
1381                },
1382                final_text,
1383            )
1384        }
1385        other => return format!("ERROR: INTERNAL: not an edit action: {other}"),
1386    };
1387
1388    // CONFLICT guard (BLAKE3, same source as headless local_range_write): verify
1389    // expected_hash against the current on-disk range BEFORE dispatch. This makes
1390    // the IDE path enforce CONFLICT identically to the headless path (which also
1391    // re-checks atomically). hash_hex == blake3::hash(...).to_hex().
1392    if let Some(exp) = &expected_hash {
1393        let s =
1394            match crate::lsp::edit_apply::offset_of(&content, range.start_line, range.start_char) {
1395                Ok(o) => o,
1396                Err(e) => return format!("ERROR: {e}"),
1397            };
1398        let e = match crate::lsp::edit_apply::offset_of(&content, range.end_line, range.end_char) {
1399            Ok(o) => o,
1400            Err(e) => return format!("ERROR: {e}"),
1401        };
1402        if e < s {
1403            return "ERROR: POSITION_OUT_OF_RANGE: end before start".to_string();
1404        }
1405        let actual = crate::core::hasher::hash_hex(&content.as_bytes()[s..e]);
1406        if *exp != actual {
1407            return format!(
1408                "ERROR: CONFLICT: range hash mismatch (expected={exp}, actual={actual})"
1409            );
1410        }
1411    }
1412
1413    let edit = crate::lsp::backend::RangeEdit {
1414        abs_path,
1415        rel_path,
1416        range,
1417        text,
1418        expected_hash,
1419    };
1420
1421    // 4) Dispatch (IDE-first, headless fallback) + format.
1422    match apply_symbol_edit(action, project_root, &edit) {
1423        Ok(res) => format_edit_result(action, &res),
1424        Err(e) => format!("ERROR: {e}"),
1425    }
1426}
1427
1428fn format_edit_result(action: &str, res: &crate::lsp::backend::EditResult) -> String {
1429    if !res.applied {
1430        return format!("{action}: not applied.");
1431    }
1432    let r = res.new_range;
1433    let body = if res.diff.is_empty() {
1434        res.edited_text.clone()
1435    } else {
1436        res.diff.clone()
1437    };
1438    format!(
1439        "{action} applied (L{}:{}-L{}:{}):\n{}",
1440        r.start_line + 1,
1441        r.start_char,
1442        r.end_line + 1,
1443        r.end_char,
1444        body
1445    )
1446}
1447
1448fn handle_inspections(
1449    args: &Value,
1450    file_path: &str,
1451    project_root: &str,
1452    uri: &lsp_types::Uri,
1453) -> String {
1454    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("run");
1455    match mode {
1456        "run" => {
1457            let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1458                let diags = backend.inspections(uri)?;
1459                Ok((diags, backend.last_truncation()))
1460            });
1461            match result {
1462                Ok((diags, meta)) => {
1463                    let mut out = format_inspections(&diags);
1464                    out.push_str(&truncation_note(diags.len(), meta));
1465                    out
1466                }
1467                Err(e) => format!("ERROR: {e}"),
1468            }
1469        }
1470        "list" => {
1471            let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1472                let items = backend.list_inspections()?;
1473                Ok((items, backend.last_truncation()))
1474            });
1475            match result {
1476                Ok((items, meta)) => {
1477                    let mut out = format_inspection_list(&items);
1478                    out.push_str(&truncation_note(items.len(), meta));
1479                    out
1480                }
1481                Err(e) => format!("ERROR: {e}"),
1482            }
1483        }
1484        other => format!("ERROR: Unknown mode '{other}' for inspections. Available: run, list."),
1485    }
1486}
1487
1488fn format_inspections(diags: &[InspectionDiag]) -> String {
1489    if diags.is_empty() {
1490        return "No inspection findings.".to_string();
1491    }
1492    let mut out = format!("{} finding(s):\n", diags.len());
1493    for d in diags {
1494        out.push_str(&format!(
1495            "  {}:{}  {}  {}\n",
1496            d.path, d.line, d.severity, d.message
1497        ));
1498    }
1499    out
1500}
1501
1502fn format_inspection_list(items: &[InspectionInfo]) -> String {
1503    if items.is_empty() {
1504        return "No inspections enabled.".to_string();
1505    }
1506    let mut out = format!("{} inspection(s):\n", items.len());
1507    for i in items {
1508        out.push_str(&format!("  {}  {}  {}\n", i.id, i.name, i.severity));
1509    }
1510    out
1511}
1512
1513fn truncation_note(shown: usize, meta: Option<crate::lsp::backend::Truncation>) -> String {
1514    match meta {
1515        Some(m) if m.truncated => {
1516            format!("\n(truncated — showing {shown} of {})\n", m.total)
1517        }
1518        _ => String::new(),
1519    }
1520}
1521
1522fn format_type_hierarchy(root: &TypeHierarchyNode) -> String {
1523    fn walk(node: &TypeHierarchyNode, depth: usize, out: &mut String) {
1524        let indent = "  ".repeat(depth);
1525        out.push_str(&format!(
1526            "{indent}{} ({}:{})\n",
1527            node.name, node.path, node.line
1528        ));
1529        for child in &node.children {
1530            walk(child, depth + 1, out);
1531        }
1532    }
1533    let mut out = String::new();
1534    walk(root, 0, &mut out);
1535    out
1536}
1537
1538fn format_symbols_overview(items: &[SymbolOverviewItem]) -> String {
1539    if items.is_empty() {
1540        return "No symbols found.".to_string();
1541    }
1542    let mut out = format!("{} symbol(s):\n", items.len());
1543    for item in items {
1544        out.push_str(&format!(
1545            "  {} {} (line {})\n",
1546            item.kind, item.name, item.line
1547        ));
1548    }
1549    out
1550}
1551
1552fn format_locations(locations: &[Location], project_root: &str) -> String {
1553    if locations.is_empty() {
1554        return "No results found.".to_string();
1555    }
1556
1557    let mut out = format!("{} location(s):\n", locations.len());
1558    for loc in locations {
1559        let path = uri_to_file_path(&loc.uri).map_or_else(
1560            || loc.uri.as_str().to_string(),
1561            |p| {
1562                p.strip_prefix(project_root)
1563                    .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
1564                    .unwrap_or(p)
1565            },
1566        );
1567
1568        let line = loc.range.start.line + 1;
1569        let col = loc.range.start.character;
1570        out.push_str(&format!("  {path}:{line}:{col}\n"));
1571    }
1572    out
1573}
1574
1575fn format_workspace_edit(edit: &lsp_types::WorkspaceEdit, project_root: &str) -> String {
1576    let mut out = String::from("Rename edits:\n");
1577    let mut file_count = 0;
1578    let mut edit_count = 0;
1579
1580    if let Some(ref changes) = edit.changes {
1581        for (uri, edits) in changes {
1582            let path = uri_to_file_path(uri).map_or_else(
1583                || uri.as_str().to_string(),
1584                |p| {
1585                    p.strip_prefix(project_root)
1586                        .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
1587                        .unwrap_or(p)
1588                },
1589            );
1590
1591            file_count += 1;
1592            out.push_str(&format!("  {path}: {} edit(s)\n", edits.len()));
1593            for e in edits {
1594                edit_count += 1;
1595                let line = e.range.start.line + 1;
1596                out.push_str(&format!("    L{line}: -> \"{}\"\n", e.new_text));
1597            }
1598        }
1599    }
1600
1601    if let Some(ref doc_changes) = edit.document_changes {
1602        match doc_changes {
1603            lsp_types::DocumentChanges::Edits(edits) => {
1604                for text_edit in edits {
1605                    let path = uri_to_file_path(&text_edit.text_document.uri)
1606                        .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
1607                    file_count += 1;
1608                    let edits_len = text_edit.edits.len();
1609                    edit_count += edits_len;
1610                    out.push_str(&format!("  {path}: {edits_len} edit(s)\n"));
1611                }
1612            }
1613            lsp_types::DocumentChanges::Operations(ops) => {
1614                for op in ops {
1615                    if let lsp_types::DocumentChangeOperation::Edit(text_edit) = op {
1616                        let path = uri_to_file_path(&text_edit.text_document.uri)
1617                            .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
1618                        file_count += 1;
1619                        let edits_len = text_edit.edits.len();
1620                        edit_count += edits_len;
1621                        out.push_str(&format!("  {path}: {edits_len} edit(s)\n"));
1622                    }
1623                }
1624            }
1625        }
1626    }
1627
1628    out.push_str(&format!(
1629        "\nTotal: {edit_count} edit(s) across {file_count} file(s)."
1630    ));
1631    out
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636    use serde_json::json;
1637
1638    /// §4.5: inner handle MUST use the (already jailed) abs_path it is given,
1639    /// never re-derive a path from raw args. A raw "../escape.rs" must never
1640    /// reach the filesystem layer; only the provided abs_path does.
1641    #[test]
1642    fn inner_handle_uses_provided_abs_path_not_raw_args() {
1643        let args = json!({"action": "references", "path": "../escape.rs", "line": 1, "column": 0});
1644        let out = super::handle(&args, "/proj", "/proj/jailed.rs");
1645        // open_file fails reading the (nonexistent) jailed file → error names abs_path.
1646        assert!(out.contains("/proj/jailed.rs"), "abs_path not used: {out}");
1647        assert!(
1648            !out.contains("../escape.rs"),
1649            "raw path leaked to fs layer: {out}"
1650        );
1651    }
1652
1653    /// `declaration` is a known action: the unknown-action arm must not fire for it,
1654    /// and its help text now advertises `declaration`.
1655    ///
1656    /// NOTE (adaptation): the real `handle` opens the file *before* the action
1657    /// match, so reaching the unknown-action help arm requires a backend. We seed
1658    /// a no-op stub backend for `rust` and point at a real temp `.rs` file so
1659    /// dispatch deterministically reaches the help text, offline, without
1660    /// starting rust-analyzer.
1661    #[test]
1662    fn unknown_action_help_lists_declaration() {
1663        struct StubBackend;
1664        impl crate::lsp::backend::LspBackend for StubBackend {
1665            fn open_file(
1666                &mut self,
1667                _uri: &lsp_types::Uri,
1668                _language_id: &str,
1669                _text: &str,
1670            ) -> Result<(), String> {
1671                Ok(())
1672            }
1673            fn references(
1674                &mut self,
1675                _uri: &lsp_types::Uri,
1676                _position: lsp_types::Position,
1677                _scope: &str,
1678            ) -> Result<Vec<lsp_types::Location>, String> {
1679                Ok(vec![])
1680            }
1681            fn definition(
1682                &mut self,
1683                _uri: &lsp_types::Uri,
1684                _position: lsp_types::Position,
1685            ) -> Result<lsp_types::GotoDefinitionResponse, String> {
1686                Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
1687            }
1688            fn implementations(
1689                &mut self,
1690                _uri: &lsp_types::Uri,
1691                _position: lsp_types::Position,
1692                _scope: &str,
1693            ) -> Result<Vec<lsp_types::Location>, String> {
1694                Ok(vec![])
1695            }
1696            fn rename(
1697                &mut self,
1698                _uri: &lsp_types::Uri,
1699                _position: lsp_types::Position,
1700                _new_name: &str,
1701            ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
1702                Ok(None)
1703            }
1704        }
1705
1706        let dir = std::env::temp_dir().join(format!("leanctx_r1_{}", std::process::id()));
1707        std::fs::create_dir_all(&dir).unwrap();
1708        let file = dir.join("x.rs");
1709        std::fs::write(&file, "fn x() {}\n").unwrap();
1710        let root = dir.to_string_lossy().to_string();
1711        let abs = file.to_string_lossy().to_string();
1712
1713        crate::lsp::router::seed_stub_backend("rust", Box::new(StubBackend));
1714
1715        let args = json!({"action": "definitely_bogus", "path": "x.rs", "line": 1});
1716        let out = super::handle(&args, &root, &abs);
1717        assert!(
1718            out.contains("declaration"),
1719            "help text missing declaration: {out}"
1720        );
1721        assert!(
1722            out.contains("inspections"),
1723            "help text missing inspections: {out}"
1724        );
1725
1726        let _ = std::fs::remove_dir_all(&dir);
1727    }
1728
1729    #[test]
1730    fn type_hierarchy_formats_indented_tree() {
1731        use crate::lsp::backend::{
1732            HierarchyDirection, LspBackend, SymbolOverviewItem, TypeHierarchyNode,
1733        };
1734
1735        struct HierBackend;
1736        impl LspBackend for HierBackend {
1737            fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
1738                Ok(())
1739            }
1740            fn references(
1741                &mut self,
1742                _u: &lsp_types::Uri,
1743                _p: lsp_types::Position,
1744                _s: &str,
1745            ) -> Result<Vec<lsp_types::Location>, String> {
1746                Ok(vec![])
1747            }
1748            fn definition(
1749                &mut self,
1750                _u: &lsp_types::Uri,
1751                _p: lsp_types::Position,
1752            ) -> Result<lsp_types::GotoDefinitionResponse, String> {
1753                Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
1754            }
1755            fn implementations(
1756                &mut self,
1757                _u: &lsp_types::Uri,
1758                _p: lsp_types::Position,
1759                _s: &str,
1760            ) -> Result<Vec<lsp_types::Location>, String> {
1761                Ok(vec![])
1762            }
1763            fn rename(
1764                &mut self,
1765                _u: &lsp_types::Uri,
1766                _p: lsp_types::Position,
1767                _n: &str,
1768            ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
1769                Ok(None)
1770            }
1771            fn type_hierarchy(
1772                &mut self,
1773                _u: &lsp_types::Uri,
1774                _p: lsp_types::Position,
1775                dir: HierarchyDirection,
1776            ) -> Result<TypeHierarchyNode, String> {
1777                assert_eq!(dir, HierarchyDirection::Subtypes);
1778                Ok(TypeHierarchyNode {
1779                    name: "Animal".into(),
1780                    path: "A.kt".into(),
1781                    line: 1,
1782                    children: vec![TypeHierarchyNode {
1783                        name: "Dog".into(),
1784                        path: "A.kt".into(),
1785                        line: 2,
1786                        children: vec![],
1787                    }],
1788                })
1789            }
1790            fn symbols_overview(
1791                &mut self,
1792                _u: &lsp_types::Uri,
1793            ) -> Result<Vec<SymbolOverviewItem>, String> {
1794                Ok(vec![SymbolOverviewItem {
1795                    name: "Animal".into(),
1796                    kind: "interface".into(),
1797                    line: 1,
1798                }])
1799            }
1800        }
1801
1802        let tree = HierBackend
1803            .type_hierarchy(
1804                &crate::lsp::client::file_path_to_uri("/p/A.kt").unwrap(),
1805                lsp_types::Position::new(0, 0),
1806                HierarchyDirection::Subtypes,
1807            )
1808            .unwrap();
1809        let out = super::format_type_hierarchy(&tree);
1810        assert!(out.contains("Animal (A.kt:1)"), "{out}");
1811        assert!(out.contains("  Dog (A.kt:2)"), "{out}"); // child indented
1812
1813        let items = HierBackend
1814            .symbols_overview(&crate::lsp::client::file_path_to_uri("/p/A.kt").unwrap())
1815            .unwrap();
1816        let out2 = super::format_symbols_overview(&items);
1817        assert!(out2.contains("interface Animal (line 1)"), "{out2}");
1818    }
1819
1820    #[test]
1821    fn parse_direction_defaults_to_supertypes() {
1822        use crate::lsp::backend::HierarchyDirection;
1823        assert_eq!(
1824            super::parse_direction(&json!({})),
1825            HierarchyDirection::Supertypes
1826        );
1827        assert_eq!(
1828            super::parse_direction(&json!({"direction": "subtypes"})),
1829            HierarchyDirection::Subtypes
1830        );
1831        assert_eq!(
1832            super::parse_direction(&json!({"direction": "supertypes"})),
1833            HierarchyDirection::Supertypes
1834        );
1835    }
1836
1837    #[test]
1838    fn resolve_name_path_unique_class() {
1839        let _lock = crate::core::data_dir::test_env_lock();
1840        let tmp = tempfile::tempdir().unwrap();
1841        let data = tmp.path().join("data");
1842        std::fs::create_dir_all(&data).unwrap();
1843        std::env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
1844
1845        let proj = tmp.path().join("proj");
1846        std::fs::create_dir_all(proj.join("src")).unwrap();
1847        std::fs::write(
1848            proj.join("Cargo.toml"),
1849            "[package]\nname=\"x\"\nversion=\"0.0.0\"\n",
1850        )
1851        .unwrap();
1852        std::fs::write(
1853            proj.join("src/lib.rs"),
1854            "pub struct UniqueZqWidget { pub a: u8 }\n",
1855        )
1856        .unwrap();
1857        let root = proj.to_string_lossy().to_string();
1858
1859        let r = super::resolve_name_path("UniqueZqWidget", &root).expect("unique resolution");
1860        assert!(r.rel_path.ends_with("lib.rs"), "got: {}", r.rel_path);
1861        assert!(r.end_line >= r.start_line && r.start_line > 0);
1862
1863        std::env::remove_var("LEAN_CTX_DATA_DIR");
1864    }
1865
1866    #[test]
1867    fn resolve_name_path_unknown_is_no_symbol() {
1868        let _lock = crate::core::data_dir::test_env_lock();
1869        let tmp = tempfile::tempdir().unwrap();
1870        let data = tmp.path().join("data");
1871        std::fs::create_dir_all(&data).unwrap();
1872        std::env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
1873
1874        let proj = tmp.path().join("proj");
1875        std::fs::create_dir_all(proj.join("src")).unwrap();
1876        std::fs::write(
1877            proj.join("Cargo.toml"),
1878            "[package]\nname=\"x\"\nversion=\"0.0.0\"\n",
1879        )
1880        .unwrap();
1881        std::fs::write(
1882            proj.join("src/lib.rs"),
1883            "pub struct UniqueZqWidget { pub a: u8 }\n",
1884        )
1885        .unwrap();
1886        let root = proj.to_string_lossy().to_string();
1887
1888        let err = super::resolve_name_path("ZzzNoSuchSymbol123", &root).unwrap_err();
1889        assert!(err.starts_with("NO_SYMBOL"), "got: {err}");
1890
1891        std::env::remove_var("LEAN_CTX_DATA_DIR");
1892    }
1893
1894    #[test]
1895    fn anchor_indent_reads_leading_whitespace() {
1896        let content = "class A {\n    fun b() {}\n}\n";
1897        assert_eq!(super::anchor_indent(content, 2), "    "); // line 2 (1-based) → 4 spaces
1898        assert_eq!(super::anchor_indent(content, 1), ""); // line 1 → none
1899    }
1900
1901    #[test]
1902    fn reindent_prefixes_first_line_only() {
1903        assert_eq!(
1904            super::reindent_first_line("fun x() {}", "    "),
1905            "    fun x() {}"
1906        );
1907        // Already-indented text is left untouched.
1908        assert_eq!(
1909            super::reindent_first_line("    fun x()", "    "),
1910            "    fun x()"
1911        );
1912    }
1913
1914    #[test]
1915    fn apply_symbol_edit_headless_replaces_range() {
1916        let dir = tempfile::tempdir().unwrap();
1917        std::fs::write(dir.path().join("Foo.txt"), "aaa\nBODY\nccc\n").unwrap();
1918        let abs = dir.path().join("Foo.txt").to_string_lossy().to_string();
1919        let edit = crate::lsp::backend::RangeEdit {
1920            abs_path: abs.clone(),
1921            rel_path: "Foo.txt".into(),
1922            range: crate::lsp::backend::TextRange0Based {
1923                start_line: 1,
1924                start_char: 0,
1925                end_line: 1,
1926                end_char: 4,
1927            },
1928            text: "NEW".into(),
1929            expected_hash: None,
1930        };
1931        // No port file under this temp dir → headless apply.
1932        let res =
1933            super::apply_symbol_edit("replace_symbol_body", dir.path().to_str().unwrap(), &edit)
1934                .unwrap();
1935        assert!(res.applied);
1936        assert_eq!(std::fs::read_to_string(&abs).unwrap(), "aaa\nNEW\nccc\n");
1937    }
1938
1939    #[test]
1940    fn handle_replace_symbol_body_via_position_fallback() {
1941        let dir = tempfile::tempdir().unwrap();
1942        std::fs::write(dir.path().join("a.rs"), "fn old() {\n  1\n}\n").unwrap();
1943        let args = serde_json::json!({
1944            "action": "replace_symbol_body",
1945            "path": "a.rs",
1946            "line": 1,
1947            "end_line": 3,
1948            "new_body": "fn new() {\n  2\n}"
1949        });
1950        let out = super::handle(&args, dir.path().to_str().unwrap(), "");
1951        assert!(out.contains("replace_symbol_body applied"), "got: {out}");
1952        let after = std::fs::read_to_string(dir.path().join("a.rs")).unwrap();
1953        assert!(after.contains("fn new()"), "file: {after}");
1954    }
1955
1956    #[test]
1957    fn handle_replace_symbol_body_conflict_on_stale_hash() {
1958        let dir = tempfile::tempdir().unwrap();
1959        std::fs::write(dir.path().join("a.rs"), "fn old() {\n  1\n}\n").unwrap();
1960        // Range = full file lines 1..=3; old content = the whole file text.
1961        let stale = serde_json::json!({
1962            "action": "replace_symbol_body",
1963            "path": "a.rs", "line": 1, "end_line": 3,
1964            "new_body": "fn new() {\n  2\n}",
1965            "expected_hash": "deadbeefnotahash"
1966        });
1967        let out = super::handle(&stale, dir.path().to_str().unwrap(), "");
1968        assert!(out.contains("CONFLICT"), "got: {out}");
1969        // file unchanged
1970        assert!(std::fs::read_to_string(dir.path().join("a.rs"))
1971            .unwrap()
1972            .contains("fn old()"));
1973    }
1974
1975    #[test]
1976    fn references_output_surfaces_truncation_note() {
1977        use lsp_types::Position;
1978        struct TruncBackend;
1979        impl crate::lsp::backend::LspBackend for TruncBackend {
1980            fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
1981                Ok(())
1982            }
1983            fn references(
1984                &mut self,
1985                _u: &lsp_types::Uri,
1986                _p: lsp_types::Position,
1987                _s: &str,
1988            ) -> Result<Vec<lsp_types::Location>, String> {
1989                let uri = crate::lsp::client::file_path_to_uri("/proj/a.rs").unwrap();
1990                Ok(vec![lsp_types::Location {
1991                    uri,
1992                    range: lsp_types::Range::default(),
1993                }])
1994            }
1995            fn definition(
1996                &mut self,
1997                _u: &lsp_types::Uri,
1998                _p: lsp_types::Position,
1999            ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2000                Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2001            }
2002            fn implementations(
2003                &mut self,
2004                _u: &lsp_types::Uri,
2005                _p: lsp_types::Position,
2006                _s: &str,
2007            ) -> Result<Vec<lsp_types::Location>, String> {
2008                Ok(vec![])
2009            }
2010            fn rename(
2011                &mut self,
2012                _u: &lsp_types::Uri,
2013                _p: lsp_types::Position,
2014                _n: &str,
2015            ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2016                Ok(None)
2017            }
2018            fn last_truncation(&self) -> Option<crate::lsp::backend::Truncation> {
2019                Some(crate::lsp::backend::Truncation {
2020                    truncated: true,
2021                    total: 742,
2022                })
2023            }
2024        }
2025        crate::lsp::router::seed_stub_backend("rust", Box::new(TruncBackend));
2026        let uri = crate::lsp::client::file_path_to_uri("/proj/a.rs").unwrap();
2027        let out = super::handle_references(
2028            "/proj/a.rs",
2029            "/proj",
2030            &uri,
2031            Position {
2032                line: 0,
2033                character: 0,
2034            },
2035            "project",
2036        );
2037        assert!(
2038            out.contains("truncated"),
2039            "expected truncation note, got: {out}"
2040        );
2041        assert!(out.contains("742"), "expected total in note, got: {out}");
2042    }
2043
2044    #[test]
2045    fn inspections_run_and_list_dispatch_and_truncation() {
2046        use lsp_types::Position;
2047        struct InspBackend;
2048        impl crate::lsp::backend::LspBackend for InspBackend {
2049            fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2050                Ok(())
2051            }
2052            fn references(
2053                &mut self,
2054                _u: &lsp_types::Uri,
2055                _p: lsp_types::Position,
2056                _s: &str,
2057            ) -> Result<Vec<lsp_types::Location>, String> {
2058                Ok(vec![])
2059            }
2060            fn definition(
2061                &mut self,
2062                _u: &lsp_types::Uri,
2063                _p: lsp_types::Position,
2064            ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2065                Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2066            }
2067            fn implementations(
2068                &mut self,
2069                _u: &lsp_types::Uri,
2070                _p: lsp_types::Position,
2071                _s: &str,
2072            ) -> Result<Vec<lsp_types::Location>, String> {
2073                Ok(vec![])
2074            }
2075            fn rename(
2076                &mut self,
2077                _u: &lsp_types::Uri,
2078                _p: lsp_types::Position,
2079                _n: &str,
2080            ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2081                Ok(None)
2082            }
2083            fn inspections(
2084                &mut self,
2085                _u: &lsp_types::Uri,
2086            ) -> Result<Vec<crate::lsp::backend::InspectionDiag>, String> {
2087                Ok(vec![crate::lsp::backend::InspectionDiag {
2088                    path: "A.kt".into(),
2089                    line: 7,
2090                    severity: "WARNING".into(),
2091                    message: "unused".into(),
2092                }])
2093            }
2094            fn list_inspections(
2095                &mut self,
2096            ) -> Result<Vec<crate::lsp::backend::InspectionInfo>, String> {
2097                Ok(vec![crate::lsp::backend::InspectionInfo {
2098                    id: "UnusedSymbol".into(),
2099                    name: "Unused declaration".into(),
2100                    severity: "WARNING".into(),
2101                }])
2102            }
2103            fn last_truncation(&self) -> Option<crate::lsp::backend::Truncation> {
2104                Some(crate::lsp::backend::Truncation {
2105                    truncated: true,
2106                    total: 99,
2107                })
2108            }
2109        }
2110        crate::lsp::router::seed_stub_backend("rust", Box::new(InspBackend));
2111        let uri = crate::lsp::client::file_path_to_uri("/proj/a.rs").unwrap();
2112
2113        // run mode (default): formats path:line SEVERITY message + truncation note
2114        let run_out = super::handle_inspections(
2115            &json!({"action": "inspections"}),
2116            "/proj/a.rs",
2117            "/proj",
2118            &uri,
2119        );
2120        assert!(run_out.contains("A.kt:7"), "run diag missing: {run_out}");
2121        assert!(
2122            run_out.contains("WARNING"),
2123            "run severity missing: {run_out}"
2124        );
2125        assert!(run_out.contains("unused"), "run message missing: {run_out}");
2126        assert!(
2127            run_out.contains("truncated"),
2128            "run truncation missing: {run_out}"
2129        );
2130        assert!(run_out.contains("99"), "run total missing: {run_out}");
2131
2132        // list mode: formats id name severity
2133        let list_out = super::handle_inspections(
2134            &json!({"action": "inspections", "mode": "list"}),
2135            "/proj/a.rs",
2136            "/proj",
2137            &uri,
2138        );
2139        assert!(
2140            list_out.contains("UnusedSymbol"),
2141            "list id missing: {list_out}"
2142        );
2143        assert!(
2144            list_out.contains("Unused declaration"),
2145            "list name missing: {list_out}"
2146        );
2147
2148        // unknown mode → defined ERROR
2149        let bad_out = super::handle_inspections(
2150            &json!({"action": "inspections", "mode": "bogus"}),
2151            "/proj/a.rs",
2152            "/proj",
2153            &uri,
2154        );
2155        assert!(
2156            bad_out.contains("ERROR"),
2157            "unknown mode not rejected: {bad_out}"
2158        );
2159        let _ = (Position::new(0, 0),); // keep import used if refactored
2160    }
2161
2162    #[test]
2163    fn usage_range_text_reads_jailed_slice() {
2164        let dir = tempfile::tempdir().unwrap();
2165        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2166        let root = dir.path().to_str().unwrap();
2167        let u = crate::lsp::backend::UsageSite {
2168            path: "a.rs".into(),
2169            range: crate::lsp::backend::TextRange0Based {
2170                start_line: 0,
2171                start_char: 4,
2172                end_line: 0,
2173                end_char: 7,
2174            },
2175            context: None,
2176        };
2177        assert_eq!(super::usage_range_text(root, &u).unwrap(), "foo");
2178    }
2179
2180    // Jail rejection only happens when the jail is compiled in. `--all-features`
2181    // pulls in `no-jail` (jail disabled), so skip there like the move/resolve jail
2182    // assertions below.
2183    #[cfg(not(feature = "no-jail"))]
2184    #[test]
2185    fn usage_range_text_rejects_jail_escape() {
2186        let dir = tempfile::tempdir().unwrap();
2187        let root = dir.path().to_str().unwrap();
2188        let u = crate::lsp::backend::UsageSite {
2189            path: "../../etc/passwd".into(),
2190            range: crate::lsp::backend::TextRange0Based {
2191                start_line: 0,
2192                start_char: 0,
2193                end_line: 0,
2194                end_char: 1,
2195            },
2196            context: None,
2197        };
2198        assert!(super::usage_range_text(root, &u).is_err());
2199    }
2200
2201    #[test]
2202    fn plan_hash_is_deterministic_and_order_independent() {
2203        let dir = tempfile::tempdir().unwrap();
2204        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2205        let root = dir.path().to_str().unwrap();
2206        let u1 = crate::lsp::backend::UsageSite {
2207            path: "a.rs".into(),
2208            range: crate::lsp::backend::TextRange0Based {
2209                start_line: 0,
2210                start_char: 4,
2211                end_line: 0,
2212                end_char: 7,
2213            },
2214            context: Some("ignored-in-hash".into()),
2215        };
2216        let u2 = crate::lsp::backend::UsageSite {
2217            path: "a.rs".into(),
2218            range: crate::lsp::backend::TextRange0Based {
2219                start_line: 1,
2220                start_char: 0,
2221                end_line: 1,
2222                end_char: 3,
2223            },
2224            context: None,
2225        };
2226        let h1 = super::plan_hash(root, &[u1.clone(), u2.clone()]).unwrap();
2227        let h2 = super::plan_hash(root, std::slice::from_ref(&u2)).unwrap(); // subset → differs
2228        let h3 = super::plan_hash(root, &[u2, u1]).unwrap(); // reversed → SAME (sorted canonical)
2229        assert_eq!(h1.len(), 64);
2230        assert_eq!(h1, h3, "hash must be order-independent");
2231        assert_ne!(h1, h2, "different usage set must differ");
2232    }
2233
2234    #[test]
2235    fn resolve_rename_target_position_fallback() {
2236        let (rel, sl, el) = super::resolve_rename_target(
2237            &serde_json::json!({"path": "a.rs", "line": 3, "end_line": 5}),
2238            "/proj",
2239        )
2240        .unwrap();
2241        assert_eq!(rel, "a.rs");
2242        assert_eq!((sl, el), (3, 5));
2243    }
2244
2245    #[test]
2246    fn resolve_rename_target_requires_line_in_fallback() {
2247        let err = super::resolve_rename_target(&serde_json::json!({"path": "a.rs"}), "/proj")
2248            .unwrap_err();
2249        assert!(err.contains("line"), "got: {err}");
2250    }
2251
2252    #[test]
2253    fn live_backend_absent_is_backend_required() {
2254        // No port file under an unlikely root → deterministic BACKEND_REQUIRED, no HTTP.
2255        let err = super::live_jetbrains_backend("/nonexistent/leanctx/proj/zzz")
2256            .err()
2257            .expect("expected Err from live_jetbrains_backend");
2258        assert!(err.starts_with("BACKEND_REQUIRED"), "got: {err}");
2259    }
2260
2261    /// Minimal backend that returns canned rename plans + records apply calls.
2262    struct RenameStub {
2263        plan: crate::lsp::backend::RenamePlan,
2264        applied_with_force: std::cell::Cell<Option<bool>>,
2265    }
2266    impl crate::lsp::backend::LspBackend for RenameStub {
2267        fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2268            Ok(())
2269        }
2270        fn references(
2271            &mut self,
2272            _u: &lsp_types::Uri,
2273            _p: lsp_types::Position,
2274            _s: &str,
2275        ) -> Result<Vec<lsp_types::Location>, String> {
2276            Ok(vec![])
2277        }
2278        fn definition(
2279            &mut self,
2280            _u: &lsp_types::Uri,
2281            _p: lsp_types::Position,
2282        ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2283            Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2284        }
2285        fn implementations(
2286            &mut self,
2287            _u: &lsp_types::Uri,
2288            _p: lsp_types::Position,
2289            _s: &str,
2290        ) -> Result<Vec<lsp_types::Location>, String> {
2291            Ok(vec![])
2292        }
2293        fn rename(
2294            &mut self,
2295            _u: &lsp_types::Uri,
2296            _p: lsp_types::Position,
2297            _n: &str,
2298        ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2299            Ok(None)
2300        }
2301        fn rename_preview(
2302            &mut self,
2303            _q: &crate::lsp::backend::RenameQuery,
2304        ) -> Result<crate::lsp::backend::RenamePlan, String> {
2305            Ok(self.plan.clone())
2306        }
2307        fn rename_apply(
2308            &mut self,
2309            req: &crate::lsp::backend::RenameApply,
2310        ) -> Result<crate::lsp::backend::RenameResult, String> {
2311            self.applied_with_force.set(Some(req.force));
2312            Ok(crate::lsp::backend::RenameResult {
2313                applied: true,
2314                changed_paths: vec!["a.rs".into()],
2315            })
2316        }
2317    }
2318
2319    fn stub_query(abs: &str) -> crate::lsp::backend::RenameQuery {
2320        crate::lsp::backend::RenameQuery {
2321            abs_path: abs.into(),
2322            rel_path: "a.rs".into(),
2323            target_range: crate::lsp::backend::TextRange0Based {
2324                start_line: 0,
2325                start_char: 4,
2326                end_line: 0,
2327                end_char: 7,
2328            },
2329            new_name: "bar".into(),
2330            search_comments: false,
2331            search_text_occurrences: false,
2332        }
2333    }
2334
2335    #[test]
2336    fn apply_blocks_on_plan_hash_mismatch() {
2337        let dir = tempfile::tempdir().unwrap();
2338        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2339        let root = dir.path().to_str().unwrap();
2340        let usage = crate::lsp::backend::UsageSite {
2341            path: "a.rs".into(),
2342            range: crate::lsp::backend::TextRange0Based {
2343                start_line: 0,
2344                start_char: 4,
2345                end_line: 0,
2346                end_char: 7,
2347            },
2348            context: None,
2349        };
2350        let mut be = RenameStub {
2351            plan: crate::lsp::backend::RenamePlan {
2352                usages: vec![usage],
2353                conflicts: vec![],
2354            },
2355            applied_with_force: std::cell::Cell::new(None),
2356        };
2357        let q = stub_query(&dir.path().join("a.rs").to_string_lossy());
2358        let out = super::render_rename_apply(&mut be, root, &q, "bar", "stalehash", false);
2359        assert!(out.contains("CONFLICT"), "got: {out}");
2360        assert_eq!(
2361            be.applied_with_force.get(),
2362            None,
2363            "apply must not run on hash mismatch"
2364        );
2365    }
2366
2367    #[test]
2368    fn apply_blocks_on_conflicts_without_force_and_passes_with_force() {
2369        let dir = tempfile::tempdir().unwrap();
2370        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2371        let root = dir.path().to_str().unwrap();
2372        let usage = crate::lsp::backend::UsageSite {
2373            path: "a.rs".into(),
2374            range: crate::lsp::backend::TextRange0Based {
2375                start_line: 0,
2376                start_char: 4,
2377                end_line: 0,
2378                end_char: 7,
2379            },
2380            context: None,
2381        };
2382        let plan = crate::lsp::backend::RenamePlan {
2383            usages: vec![usage.clone()],
2384            conflicts: vec![crate::lsp::backend::Conflict {
2385                path: "a.rs".into(),
2386                range: None,
2387                message: "clash".into(),
2388            }],
2389        };
2390        let hash = super::plan_hash(root, &plan.usages).unwrap();
2391        let q = stub_query(&dir.path().join("a.rs").to_string_lossy());
2392
2393        // force=false → CONFLICT, apply not called.
2394        let mut be = RenameStub {
2395            plan: plan.clone(),
2396            applied_with_force: std::cell::Cell::new(None),
2397        };
2398        let out = super::render_rename_apply(&mut be, root, &q, "bar", &hash, false);
2399        assert!(out.contains("CONFLICT"), "got: {out}");
2400        assert_eq!(be.applied_with_force.get(), None);
2401
2402        // force=true → applies, force passed through.
2403        let mut be2 = RenameStub {
2404            plan,
2405            applied_with_force: std::cell::Cell::new(None),
2406        };
2407        let out2 = super::render_rename_apply(&mut be2, root, &q, "bar", &hash, true);
2408        assert!(out2.contains("applied"), "got: {out2}");
2409        assert_eq!(be2.applied_with_force.get(), Some(true));
2410    }
2411
2412    #[test]
2413    fn apply_success_emits_diff_and_evicts() {
2414        let dir = tempfile::tempdir().unwrap();
2415        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2416        let root = dir.path().to_str().unwrap();
2417        let usage = crate::lsp::backend::UsageSite {
2418            path: "a.rs".into(),
2419            range: crate::lsp::backend::TextRange0Based {
2420                start_line: 0,
2421                start_char: 4,
2422                end_line: 0,
2423                end_char: 7,
2424            },
2425            context: None,
2426        };
2427        let plan = crate::lsp::backend::RenamePlan {
2428            usages: vec![usage],
2429            conflicts: vec![],
2430        };
2431        let hash = super::plan_hash(root, &plan.usages).unwrap();
2432        let mut be = RenameStub {
2433            plan,
2434            applied_with_force: std::cell::Cell::new(None),
2435        };
2436        let q = stub_query(&dir.path().join("a.rs").to_string_lossy());
2437        let out = super::render_rename_apply(&mut be, root, &q, "bar", &hash, false);
2438        assert!(out.contains("applied"), "got: {out}");
2439        assert!(out.contains("\"foo\" → \"bar\""), "diff missing: {out}");
2440    }
2441
2442    #[test]
2443    fn preview_renders_plan_hash_and_files() {
2444        let dir = tempfile::tempdir().unwrap();
2445        std::fs::write(dir.path().join("usage.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2446        let root = dir.path().to_str().unwrap();
2447        let usage = crate::lsp::backend::UsageSite {
2448            path: "usage.rs".into(),
2449            range: crate::lsp::backend::TextRange0Based {
2450                start_line: 0,
2451                start_char: 4,
2452                end_line: 0,
2453                end_char: 7,
2454            },
2455            context: None,
2456        };
2457        let plan = crate::lsp::backend::RenamePlan {
2458            usages: vec![usage],
2459            conflicts: vec![],
2460        };
2461        let mut be = RenameStub {
2462            plan,
2463            applied_with_force: std::cell::Cell::new(None),
2464        };
2465        let mut q = stub_query(&dir.path().join("usage.rs").to_string_lossy());
2466        q.rel_path = "decl.rs".into();
2467        let out = super::render_rename_preview(&mut be, root, &q, "bar");
2468        assert!(out.contains("plan_hash:"), "got: {out}");
2469        assert!(out.contains("usages: 1"), "got: {out}");
2470        assert!(out.contains("files: 2"), "got: {out}");
2471        assert!(out.contains("usage.rs: 1 usage"), "got: {out}");
2472    }
2473
2474    #[test]
2475    fn handle_rename_preview_without_ide_is_backend_required() {
2476        let dir = tempfile::tempdir().unwrap();
2477        std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2478        let root = dir.path().to_str().unwrap();
2479        // No port file under this temp root → BACKEND_REQUIRED before any HTTP.
2480        let args = serde_json::json!({
2481            "action": "rename_preview", "path": "a.rs", "line": 1, "new_name": "bar"
2482        });
2483        let out = super::handle(&args, root, "");
2484        assert!(out.contains("BACKEND_REQUIRED"), "got: {out}");
2485    }
2486
2487    #[test]
2488    fn handle_rename_apply_requires_plan_hash() {
2489        let dir = tempfile::tempdir().unwrap();
2490        std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2491        let root = dir.path().to_str().unwrap();
2492        let args = serde_json::json!({
2493            "action": "rename_apply", "path": "a.rs", "line": 1, "new_name": "bar"
2494        });
2495        let out = super::handle(&args, root, "");
2496        assert!(out.contains("plan_hash"), "got: {out}");
2497    }
2498
2499    #[test]
2500    fn handle_safe_delete_preview_without_ide_is_backend_required() {
2501        let dir = tempfile::tempdir().unwrap();
2502        std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2503        let root = dir.path().to_str().unwrap();
2504        let args = serde_json::json!({"action": "safe_delete_preview", "path": "a.rs", "line": 1});
2505        let out = super::handle(&args, root, "");
2506        assert!(out.contains("BACKEND_REQUIRED"), "got: {out}");
2507    }
2508
2509    #[test]
2510    fn handle_safe_delete_apply_requires_plan_hash() {
2511        let dir = tempfile::tempdir().unwrap();
2512        std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2513        let root = dir.path().to_str().unwrap();
2514        let args = serde_json::json!({"action": "safe_delete_apply", "path": "a.rs", "line": 1});
2515        let out = super::handle(&args, root, "");
2516        assert!(out.contains("plan_hash"), "got: {out}");
2517    }
2518
2519    #[test]
2520    fn resolve_move_target_requires_exactly_one_field() {
2521        let dir = tempfile::tempdir().unwrap();
2522        std::fs::create_dir_all(dir.path().join("app/moved")).unwrap();
2523        let root = dir.path().to_str().unwrap();
2524
2525        // Neither set → INVALID_TARGET.
2526        let err = super::resolve_move_target(&serde_json::json!({}), root).unwrap_err();
2527        assert!(err.starts_with("INVALID_TARGET"), "got: {err}");
2528
2529        // Both set → INVALID_TARGET.
2530        let err2 = super::resolve_move_target(
2531            &serde_json::json!({"target_path": "app/moved", "target_parent": "Other"}),
2532            root,
2533        )
2534        .unwrap_err();
2535        assert!(err2.starts_with("INVALID_TARGET"), "got: {err2}");
2536    }
2537
2538    // Jail rejection only happens when the jail is compiled in. `--all-features`
2539    // pulls in `no-jail` (jail disabled), so skip there like every other jail
2540    // assertion (see e.g. server::multi_path tests).
2541    #[cfg(not(feature = "no-jail"))]
2542    #[test]
2543    fn resolve_move_target_path_is_jailed() {
2544        let dir = tempfile::tempdir().unwrap();
2545        std::fs::create_dir_all(dir.path().join("app/moved")).unwrap();
2546        let root = dir.path().to_str().unwrap();
2547
2548        // In-jail path resolves to a MoveTarget::Path.
2549        let t = super::resolve_move_target(&serde_json::json!({"target_path": "app/moved"}), root)
2550            .unwrap();
2551        match t {
2552            crate::lsp::backend::MoveTarget::Path { rel_path, .. } => {
2553                assert_eq!(rel_path, "app/moved");
2554            }
2555            other @ crate::lsp::backend::MoveTarget::Parent { .. } => {
2556                panic!("expected Path, got {other:?}")
2557            }
2558        }
2559
2560        // Escape attempt → INVALID_TARGET (jail violation, before any backend call).
2561        let err =
2562            super::resolve_move_target(&serde_json::json!({"target_path": "../../etc/skel"}), root)
2563                .unwrap_err();
2564        assert!(err.starts_with("INVALID_TARGET"), "got: {err}");
2565    }
2566
2567    /// Minimal backend for the move renderers: canned plan + recorded apply flags + changed paths.
2568    struct MoveStub {
2569        plan: crate::lsp::backend::RenamePlan,
2570        applied_with_force: std::cell::Cell<Option<bool>>,
2571    }
2572    impl crate::lsp::backend::LspBackend for MoveStub {
2573        fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2574            Ok(())
2575        }
2576        fn references(
2577            &mut self,
2578            _u: &lsp_types::Uri,
2579            _p: lsp_types::Position,
2580            _s: &str,
2581        ) -> Result<Vec<lsp_types::Location>, String> {
2582            Ok(vec![])
2583        }
2584        fn definition(
2585            &mut self,
2586            _u: &lsp_types::Uri,
2587            _p: lsp_types::Position,
2588        ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2589            Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2590        }
2591        fn implementations(
2592            &mut self,
2593            _u: &lsp_types::Uri,
2594            _p: lsp_types::Position,
2595            _s: &str,
2596        ) -> Result<Vec<lsp_types::Location>, String> {
2597            Ok(vec![])
2598        }
2599        fn rename(
2600            &mut self,
2601            _u: &lsp_types::Uri,
2602            _p: lsp_types::Position,
2603            _n: &str,
2604        ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2605            Ok(None)
2606        }
2607        fn move_preview(
2608            &mut self,
2609            _q: &crate::lsp::backend::MoveQuery,
2610        ) -> Result<crate::lsp::backend::RenamePlan, String> {
2611            Ok(self.plan.clone())
2612        }
2613        fn move_apply(
2614            &mut self,
2615            req: &crate::lsp::backend::MoveApply,
2616        ) -> Result<crate::lsp::backend::RenameResult, String> {
2617            self.applied_with_force.set(Some(req.force));
2618            Ok(crate::lsp::backend::RenameResult {
2619                applied: true,
2620                changed_paths: vec!["app/moved/Widget.kt".into()],
2621            })
2622        }
2623    }
2624
2625    fn move_query(abs: &str) -> crate::lsp::backend::MoveQuery {
2626        crate::lsp::backend::MoveQuery {
2627            abs_path: abs.into(),
2628            rel_path: "a.rs".into(),
2629            src_range: crate::lsp::backend::TextRange0Based {
2630                start_line: 0,
2631                start_char: 4,
2632                end_line: 0,
2633                end_char: 7,
2634            },
2635            target: crate::lsp::backend::MoveTarget::Path {
2636                abs_path: "/p/app/moved".into(),
2637                rel_path: "app/moved".into(),
2638            },
2639        }
2640    }
2641
2642    #[test]
2643    fn move_apply_gates_then_evicts() {
2644        let dir = tempfile::tempdir().unwrap();
2645        std::fs::create_dir_all(dir.path().join("app/moved")).unwrap();
2646        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2647        std::fs::write(dir.path().join("app/moved/Widget.kt"), "// moved\n").unwrap();
2648        let root = dir.path().to_str().unwrap();
2649        let usage = crate::lsp::backend::UsageSite {
2650            path: "a.rs".into(),
2651            range: crate::lsp::backend::TextRange0Based {
2652                start_line: 0,
2653                start_char: 4,
2654                end_line: 0,
2655                end_char: 7,
2656            },
2657            context: None,
2658        };
2659        let plan = crate::lsp::backend::RenamePlan {
2660            usages: vec![usage],
2661            conflicts: vec![],
2662        };
2663        let hash = super::plan_hash(root, &plan.usages).unwrap();
2664        let q = move_query(&dir.path().join("a.rs").to_string_lossy());
2665
2666        // hash mismatch → CONFLICT, apply not called.
2667        let mut be = MoveStub {
2668            plan: plan.clone(),
2669            applied_with_force: std::cell::Cell::new(None),
2670        };
2671        let out = super::render_move_apply(&mut be, root, &q, "stalehash", false);
2672        assert!(out.contains("CONFLICT"), "got: {out}");
2673        assert_eq!(be.applied_with_force.get(), None);
2674
2675        // matching hash + force → applies, force passed through, changed path jailed+evicted.
2676        let mut be2 = MoveStub {
2677            plan,
2678            applied_with_force: std::cell::Cell::new(None),
2679        };
2680        let out2 = super::render_move_apply(&mut be2, root, &q, &hash, true);
2681        assert!(out2.contains("applied"), "got: {out2}");
2682        assert_eq!(be2.applied_with_force.get(), Some(true));
2683    }
2684
2685    // See above: jail rejection requires the jail compiled in (skipped under no-jail).
2686    #[cfg(not(feature = "no-jail"))]
2687    #[test]
2688    fn move_apply_rejects_out_of_jail_changed_path() {
2689        let dir = tempfile::tempdir().unwrap();
2690        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2691        let root = dir.path().to_str().unwrap();
2692        let usage = crate::lsp::backend::UsageSite {
2693            path: "a.rs".into(),
2694            range: crate::lsp::backend::TextRange0Based {
2695                start_line: 0,
2696                start_char: 4,
2697                end_line: 0,
2698                end_char: 7,
2699            },
2700            context: None,
2701        };
2702        // Stub returns an out-of-jail changed path (stage-3 jail must reject it post-apply).
2703        struct EscapeStub {
2704            plan: crate::lsp::backend::RenamePlan,
2705        }
2706        impl crate::lsp::backend::LspBackend for EscapeStub {
2707            fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2708                Ok(())
2709            }
2710            fn references(
2711                &mut self,
2712                _u: &lsp_types::Uri,
2713                _p: lsp_types::Position,
2714                _s: &str,
2715            ) -> Result<Vec<lsp_types::Location>, String> {
2716                Ok(vec![])
2717            }
2718            fn definition(
2719                &mut self,
2720                _u: &lsp_types::Uri,
2721                _p: lsp_types::Position,
2722            ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2723                Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2724            }
2725            fn implementations(
2726                &mut self,
2727                _u: &lsp_types::Uri,
2728                _p: lsp_types::Position,
2729                _s: &str,
2730            ) -> Result<Vec<lsp_types::Location>, String> {
2731                Ok(vec![])
2732            }
2733            fn rename(
2734                &mut self,
2735                _u: &lsp_types::Uri,
2736                _p: lsp_types::Position,
2737                _n: &str,
2738            ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2739                Ok(None)
2740            }
2741            fn move_preview(
2742                &mut self,
2743                _q: &crate::lsp::backend::MoveQuery,
2744            ) -> Result<crate::lsp::backend::RenamePlan, String> {
2745                Ok(self.plan.clone())
2746            }
2747            fn move_apply(
2748                &mut self,
2749                _r: &crate::lsp::backend::MoveApply,
2750            ) -> Result<crate::lsp::backend::RenameResult, String> {
2751                Ok(crate::lsp::backend::RenameResult {
2752                    applied: true,
2753                    changed_paths: vec!["../../etc/passwd".into()],
2754                })
2755            }
2756        }
2757        let plan = crate::lsp::backend::RenamePlan {
2758            usages: vec![usage],
2759            conflicts: vec![],
2760        };
2761        let hash = super::plan_hash(root, &plan.usages).unwrap();
2762        let mut be = EscapeStub { plan };
2763        let q = move_query(&dir.path().join("a.rs").to_string_lossy());
2764        let out = super::render_move_apply(&mut be, root, &q, &hash, false);
2765        assert!(out.contains("jail"), "expected jail rejection, got: {out}");
2766    }
2767
2768    #[test]
2769    fn handle_move_preview_invalid_target_before_backend() {
2770        let dir = tempfile::tempdir().unwrap();
2771        std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2772        let root = dir.path().to_str().unwrap();
2773        // No target → INVALID_TARGET, and crucially BEFORE BACKEND_REQUIRED (no live IDE here).
2774        let args = serde_json::json!({"action": "move_preview", "path": "a.rs", "line": 1});
2775        let out = super::handle(&args, root, "");
2776        assert!(out.contains("INVALID_TARGET"), "got: {out}");
2777        assert!(
2778            !out.contains("BACKEND_REQUIRED"),
2779            "target gate must precede backend gate: {out}"
2780        );
2781    }
2782
2783    #[test]
2784    fn handle_move_apply_requires_plan_hash() {
2785        let dir = tempfile::tempdir().unwrap();
2786        std::fs::create_dir_all(dir.path().join("x")).unwrap();
2787        std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2788        let root = dir.path().to_str().unwrap();
2789        let args = serde_json::json!({"action": "move_apply", "path": "a.rs", "line": 1, "target_path": "x"});
2790        let out = super::handle(&args, root, "");
2791        assert!(out.contains("plan_hash"), "got: {out}");
2792    }
2793
2794    #[test]
2795    fn unknown_action_help_lists_rename_actions() {
2796        // Resolution happens before backend selection for rename actions, so an
2797        // empty new_name short-circuits with a clear ERROR mentioning new_name.
2798        let args = serde_json::json!({"action": "rename_preview", "path": "a.rs", "line": 1});
2799        let out = super::handle(&args, "/proj", "");
2800        assert!(out.contains("new_name"), "got: {out}");
2801    }
2802
2803    /// Minimal backend for the safe_delete renderers: canned plan + recorded apply flags.
2804    struct SafeDeleteStub {
2805        plan: crate::lsp::backend::RenamePlan,
2806        applied: std::cell::Cell<Option<(bool, bool)>>, // (force, propagate)
2807    }
2808    impl crate::lsp::backend::LspBackend for SafeDeleteStub {
2809        fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2810            Ok(())
2811        }
2812        fn references(
2813            &mut self,
2814            _u: &lsp_types::Uri,
2815            _p: lsp_types::Position,
2816            _s: &str,
2817        ) -> Result<Vec<lsp_types::Location>, String> {
2818            Ok(vec![])
2819        }
2820        fn definition(
2821            &mut self,
2822            _u: &lsp_types::Uri,
2823            _p: lsp_types::Position,
2824        ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2825            Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2826        }
2827        fn implementations(
2828            &mut self,
2829            _u: &lsp_types::Uri,
2830            _p: lsp_types::Position,
2831            _s: &str,
2832        ) -> Result<Vec<lsp_types::Location>, String> {
2833            Ok(vec![])
2834        }
2835        fn rename(
2836            &mut self,
2837            _u: &lsp_types::Uri,
2838            _p: lsp_types::Position,
2839            _n: &str,
2840        ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2841            Ok(None)
2842        }
2843        fn safe_delete_preview(
2844            &mut self,
2845            _q: &crate::lsp::backend::SafeDeleteQuery,
2846        ) -> Result<crate::lsp::backend::RenamePlan, String> {
2847            Ok(self.plan.clone())
2848        }
2849        fn safe_delete_apply(
2850            &mut self,
2851            req: &crate::lsp::backend::SafeDeleteApply,
2852        ) -> Result<crate::lsp::backend::RenameResult, String> {
2853            self.applied.set(Some((req.force, req.propagate)));
2854            Ok(crate::lsp::backend::RenameResult {
2855                applied: true,
2856                changed_paths: vec!["Widget.kt".into()],
2857            })
2858        }
2859    }
2860
2861    fn safe_delete_query(abs: &str) -> crate::lsp::backend::SafeDeleteQuery {
2862        crate::lsp::backend::SafeDeleteQuery {
2863            abs_path: abs.into(),
2864            rel_path: "a.rs".into(),
2865            src_range: crate::lsp::backend::TextRange0Based {
2866                start_line: 0,
2867                start_char: 4,
2868                end_line: 0,
2869                end_char: 7,
2870            },
2871        }
2872    }
2873
2874    #[test]
2875    fn safe_delete_apply_blocks_on_remaining_refs_without_force() {
2876        let dir = tempfile::tempdir().unwrap();
2877        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2878        let root = dir.path().to_str().unwrap();
2879        let usage = crate::lsp::backend::UsageSite {
2880            path: "a.rs".into(),
2881            range: crate::lsp::backend::TextRange0Based {
2882                start_line: 0,
2883                start_char: 4,
2884                end_line: 0,
2885                end_char: 7,
2886            },
2887            context: None,
2888        };
2889        // A remaining reference = a blocking conflict (spec §5.4).
2890        let plan = crate::lsp::backend::RenamePlan {
2891            usages: vec![usage.clone()],
2892            conflicts: vec![crate::lsp::backend::Conflict {
2893                path: "a.rs".into(),
2894                range: None,
2895                message: "still referenced".into(),
2896            }],
2897        };
2898        let hash = super::plan_hash(root, &plan.usages).unwrap();
2899        let q = safe_delete_query(&dir.path().join("a.rs").to_string_lossy());
2900
2901        // force=false → CONFLICT, apply not called.
2902        let mut be = SafeDeleteStub {
2903            plan: plan.clone(),
2904            applied: std::cell::Cell::new(None),
2905        };
2906        let out = super::render_safe_delete_apply(&mut be, root, &q, &hash, false, false);
2907        assert!(out.contains("CONFLICT"), "got: {out}");
2908        assert_eq!(be.applied.get(), None);
2909
2910        // force=true → applies, force+propagate passed through.
2911        let mut be2 = SafeDeleteStub {
2912            plan,
2913            applied: std::cell::Cell::new(None),
2914        };
2915        let out2 = super::render_safe_delete_apply(&mut be2, root, &q, &hash, true, true);
2916        assert!(
2917            out2.contains("deleted") || out2.contains("applied"),
2918            "got: {out2}"
2919        );
2920        assert_eq!(be2.applied.get(), Some((true, true)));
2921    }
2922
2923    #[test]
2924    fn safe_delete_apply_blocks_on_plan_hash_mismatch() {
2925        let dir = tempfile::tempdir().unwrap();
2926        std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2927        let root = dir.path().to_str().unwrap();
2928        let usage = crate::lsp::backend::UsageSite {
2929            path: "a.rs".into(),
2930            range: crate::lsp::backend::TextRange0Based {
2931                start_line: 0,
2932                start_char: 4,
2933                end_line: 0,
2934                end_char: 7,
2935            },
2936            context: None,
2937        };
2938        let mut be = SafeDeleteStub {
2939            plan: crate::lsp::backend::RenamePlan {
2940                usages: vec![usage],
2941                conflicts: vec![],
2942            },
2943            applied: std::cell::Cell::new(None),
2944        };
2945        let q = safe_delete_query(&dir.path().join("a.rs").to_string_lossy());
2946        let out = super::render_safe_delete_apply(&mut be, root, &q, "stalehash", false, false);
2947        assert!(out.contains("CONFLICT"), "got: {out}");
2948        assert_eq!(be.applied.get(), None);
2949    }
2950
2951    /// Minimal backend for the inline renderers: canned preview plan (with
2952    /// optional conflicts) + a no-op apply. Mirrors SafeDeleteStub above, but the
2953    /// inline path has NO force flag, so the stub records nothing.
2954    struct InlineStub {
2955        conflicts: Vec<crate::lsp::backend::Conflict>,
2956    }
2957    impl crate::lsp::backend::LspBackend for InlineStub {
2958        fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2959            Ok(())
2960        }
2961        fn references(
2962            &mut self,
2963            _u: &lsp_types::Uri,
2964            _p: lsp_types::Position,
2965            _s: &str,
2966        ) -> Result<Vec<lsp_types::Location>, String> {
2967            Ok(vec![])
2968        }
2969        fn definition(
2970            &mut self,
2971            _u: &lsp_types::Uri,
2972            _p: lsp_types::Position,
2973        ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2974            Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2975        }
2976        fn implementations(
2977            &mut self,
2978            _u: &lsp_types::Uri,
2979            _p: lsp_types::Position,
2980            _s: &str,
2981        ) -> Result<Vec<lsp_types::Location>, String> {
2982            Ok(vec![])
2983        }
2984        fn rename(
2985            &mut self,
2986            _u: &lsp_types::Uri,
2987            _p: lsp_types::Position,
2988            _n: &str,
2989        ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2990            Ok(None)
2991        }
2992        fn inline_preview(
2993            &mut self,
2994            _q: &crate::lsp::backend::InlineQuery,
2995        ) -> Result<crate::lsp::backend::RenamePlan, String> {
2996            Ok(crate::lsp::backend::RenamePlan {
2997                usages: vec![],
2998                conflicts: self.conflicts.clone(),
2999            })
3000        }
3001        fn inline_apply(
3002            &mut self,
3003            _r: &crate::lsp::backend::InlineApply,
3004        ) -> Result<crate::lsp::backend::RenameResult, String> {
3005            Ok(crate::lsp::backend::RenameResult {
3006                applied: true,
3007                changed_paths: vec![],
3008            })
3009        }
3010    }
3011
3012    fn inline_query(abs: &str) -> crate::lsp::backend::InlineQuery {
3013        crate::lsp::backend::InlineQuery {
3014            abs_path: abs.to_string(),
3015            rel_path: "Calc.kt".to_string(),
3016            src_range: crate::lsp::backend::TextRange0Based {
3017                start_line: 0,
3018                start_char: 0,
3019                end_line: 0,
3020                end_char: 0,
3021            },
3022            keep_definition: false,
3023        }
3024    }
3025
3026    #[test]
3027    fn handle_inline_apply_requires_plan_hash() {
3028        let args = serde_json::json!({ "action": "inline_apply", "name_path": "Calc/tmp" });
3029        let out = super::handle_inline_refactor("inline_apply", &args, "/nonexistent-root");
3030        assert!(out.contains("plan_hash"), "got: {out}");
3031    }
3032
3033    #[test]
3034    fn handle_inline_preview_without_ide_is_backend_required() {
3035        let dir = tempfile::tempdir().unwrap();
3036        std::fs::write(dir.path().join("Calc.kt"), "val tmp = 1\n").unwrap();
3037        let root = dir.path().to_str().unwrap();
3038        // File exists → flow reaches the live-IDE gate; no port file → BACKEND_REQUIRED.
3039        let args = serde_json::json!({ "action": "inline_preview", "path": "Calc.kt", "line": 1 });
3040        let out = super::handle_inline_refactor("inline_preview", &args, root);
3041        assert!(out.contains("BACKEND_REQUIRED"), "got: {out}");
3042    }
3043
3044    #[test]
3045    fn inline_apply_blocks_on_conflicts_with_no_force_path() {
3046        // A conflicting plan must ALWAYS produce CONFLICT — there is no force arg to pass.
3047        let mut be = InlineStub {
3048            conflicts: vec![crate::lsp::backend::Conflict {
3049                path: "Calc.kt".into(),
3050                range: None,
3051                message: "recursive".into(),
3052            }],
3053        };
3054        let dir = tempfile::tempdir().unwrap();
3055        let f = dir.path().join("Calc.kt");
3056        std::fs::write(&f, "val tmp = 1\n").unwrap();
3057        let q = inline_query(f.to_str().unwrap());
3058        // expected_hash is irrelevant: the conflict gate fires regardless.
3059        let out = super::render_inline_apply(&mut be, dir.path().to_str().unwrap(), &q, "deadbeef");
3060        assert!(out.contains("CONFLICT"), "got: {out}");
3061    }
3062
3063    #[test]
3064    fn reformat_invalid_target_when_no_address() {
3065        let args = serde_json::json!({ "action": "reformat" });
3066        let out = super::handle_reformat_refactor(&args, env!("CARGO_MANIFEST_DIR"));
3067        assert!(out.contains("INVALID_TARGET"), "got: {out}");
3068    }
3069
3070    #[test]
3071    fn reformat_address_dispatch_resolves_scope() {
3072        // path alone → File; path+line → Region; name_path → Symbol.
3073        let dir = tempfile::tempdir().unwrap();
3074        let f = dir.path().join("M.kt");
3075        std::fs::write(&f, "fun a(){}\nfun b(){}\n").unwrap();
3076        let root = dir.path().to_str().unwrap();
3077
3078        let file_args = serde_json::json!({ "action": "reformat", "path": "M.kt" });
3079        let (_abs, _rel, scope) = super::resolve_reformat_scope(&file_args, root).unwrap();
3080        assert!(matches!(scope, crate::lsp::backend::ReformatScope::File));
3081
3082        let region_args =
3083            serde_json::json!({ "action": "reformat", "path": "M.kt", "line": 1, "end_line": 2 });
3084        let (_a, _r, scope) = super::resolve_reformat_scope(&region_args, root).unwrap();
3085        assert!(matches!(
3086            scope,
3087            crate::lsp::backend::ReformatScope::Region { .. }
3088        ));
3089    }
3090
3091    #[test]
3092    fn reformat_without_ide_is_backend_required() {
3093        let args = serde_json::json!({ "action": "reformat", "path": "M.kt" });
3094        let out = super::handle_reformat_refactor(&args, env!("CARGO_MANIFEST_DIR"));
3095        // Either resolved scope then BACKEND_REQUIRED, or FILE_NOT_FOUND if M.kt absent in manifest.
3096        assert!(
3097            out.contains("BACKEND_REQUIRED") || out.contains("FILE_NOT_FOUND"),
3098            "got: {out}"
3099        );
3100    }
3101}