Skip to main content

lean_ctx/tools/
ctx_refactor.rs

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