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