refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture

Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
+909
View File
@@ -0,0 +1,909 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::fmt::Write;
use crate::app::lsp::path_to_lsp_uri;
use crate::tool::{Tool, ToolCtx};
pub struct LspConnect;
impl Tool for LspConnect {
fn name(&self) -> &'static str {
"lsp_connect"
}
fn description(&self) -> &'static str {
"Connect to a Language Server Protocol (LSP) server for a programming language. \
Known file extensions for the language are auto-registered, enabling other lsp_* \
tools to auto-detect this server when `server` is omitted."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Short name for this LSP connection (e.g. 'rust', 'typescript')"
},
"command": {
"type": "string",
"description": "The LSP server binary to spawn (e.g. 'rust-analyzer', 'typescript-language-server')"
},
"args": {
"type": "array",
"items": { "type": "string" },
"description": "Command-line arguments for the LSP server"
},
"language_id": {
"type": "string",
"description": "Language identifier (e.g. 'rust', 'typescript', 'python')"
}
},
"required": ["name", "command", "language_id"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: name"))?;
let command = args
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: command"))?;
let language_id = args
.get("language_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: language_id"))?;
let extra_args: Vec<String> = args
.get("args")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let mut manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
manager.connect(command, &extra_args, language_id)?;
// Auto-register this server's known extensions so lsp_diagnostics /
// lsp_hover / lsp_completion / lsp_definition / lsp_references can
// auto-detect it later without an explicit `server` argument.
let known_exts = known_extensions_for(language_id);
if !known_exts.is_empty() {
manager.register_extensions(language_id, known_exts);
}
let client_arc = manager.get_client(language_id);
let caps = client_arc
.and_then(|c| {
c.lock()
.ok()
.map(|guard| guard.server_capabilities().clone())
})
.unwrap_or_default();
let caps_summary = serde_json::to_string_pretty(&caps).unwrap_or_else(|_| "{}".to_string());
Ok(format!(
"Connected to LSP server '{name}' (language: {language_id})\nServer capabilities:\n{caps_summary}"
))
}
}
pub struct LspDiagnostics;
impl Tool for LspDiagnostics {
fn name(&self) -> &'static str {
"lsp_diagnostics"
}
fn description(&self) -> &'static str {
"Get diagnostics (errors, warnings, hints) for a file from an LSP server. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
"description": "Path to the file to analyze (relative to workspace root)"
},
"text": {
"type": "string",
"description": "The full text content of the file"
}
},
"required": ["path", "text"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let text = args
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: text"))?;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
let client_arc = manager
.get_client(server_name)
.ok_or_else(|| anyhow!("LSP server '{server_name}' not found"))?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
match client.collect_diagnostics(&uri, &language_id, text) {
Ok(diags) => {
let diags_array = diags.as_array().cloned().unwrap_or_default();
if diags_array.is_empty() {
return Ok("No diagnostics found for this file.".to_string());
}
let mut output = String::from("Diagnostics:\n");
for d in &diags_array {
let range = d.get("range").and_then(|r| r.get("start"));
let severity = match d
.get("severity")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0)
{
1 => "ERROR",
2 => "WARNING",
3 => "INFO",
4 => "HINT",
_ => "NOTE",
};
let message = d.get("message").and_then(|m| m.as_str()).unwrap_or("?");
let line = range
.and_then(|r| r.get("line"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let col = range
.and_then(|r| r.get("character"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let code = d
.get("code")
.and_then(|c| {
c.as_str().or_else(|| {
c.as_i64()
.map(|n| Box::leak(Box::new(n.to_string())))
.map(|s| s.as_str())
})
})
.unwrap_or("");
let code_str = if code.is_empty() {
String::new()
} else {
format!(" [{code}]")
};
writeln!(
output,
" {}:{}:{} - {}{}: {}",
rel_path,
line + 1,
col,
severity,
code_str,
message
)
.unwrap();
}
Ok(output)
}
Err(e) => {
if e.to_string().contains("timed out") {
Ok("Diagnostics request timed out. The server may still be initializing. Try again in a moment.".to_string())
} else {
Err(e)
}
}
}
}
}
pub struct LspHover;
impl Tool for LspHover {
fn name(&self) -> &'static str {
"lsp_hover"
}
fn description(&self) -> &'static str {
"Get hover information (type signature, documentation) at a cursor position in a file. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
"description": "Path to the file (relative to workspace root)"
},
"line": {
"type": "integer",
"description": "Line number (0-based)"
},
"column": {
"type": "integer",
"description": "Column number (0-based)"
},
"language_id": {
"type": "string",
"description": "Language identifier (e.g. 'rust', 'typescript'). Optional if already set via lsp_connect."
}
},
"required": ["path", "line", "column"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name).unwrap_or_else(|| {
args.get("language_id")
.and_then(|v| v.as_str())
.unwrap_or("plaintext")
.to_string()
});
let client_arc = manager.get_client(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.hover(&uri, line, column);
let _ = client.did_close(&uri);
match result {
Ok(hover_result) => {
if hover_result == Value::Null {
return Ok("No hover information available at this position.".to_string());
}
let contents = hover_result.get("contents");
let range = hover_result.get("range");
let mut output = String::new();
if let Some(range_val) = range {
if let Some(start) = range_val.get("start") {
let rl = start
.get("line")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let rc = start
.get("character")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
writeln!(output, "Range: {}:{}", rl + 1, rc + 1).unwrap();
}
}
if let Some(contents_val) = contents {
output.push_str(&format_hover_contents(contents_val));
} else {
output
.push_str(&serde_json::to_string_pretty(&hover_result).unwrap_or_default());
}
Ok(output)
}
Err(e) => Err(e),
}
}
}
fn format_hover_contents(contents: &Value) -> String {
let mut out = String::new();
match contents {
Value::String(s) => {
out.push_str(s);
}
Value::Object(map) => {
if let Some(kind) = map.get("kind").and_then(|k| k.as_str()) {
write!(out, "[{kind}] ").unwrap();
}
if let Some(value) = map.get("value").and_then(|v| v.as_str()) {
out.push_str(value);
}
}
Value::Array(arr) => {
for (i, item) in arr.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format_hover_contents(item));
}
}
_ => {
out.push_str(&serde_json::to_string_pretty(contents).unwrap_or_default());
}
}
out
}
pub struct LspCompletion;
impl Tool for LspCompletion {
fn name(&self) -> &'static str {
"lsp_completion"
}
fn description(&self) -> &'static str {
"Get code completion suggestions at a cursor position from an LSP server. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
"description": "Path to the file (relative to workspace root)"
},
"line": {
"type": "integer",
"description": "Line number (0-based)"
},
"column": {
"type": "integer",
"description": "Column number (0-based)"
}
},
"required": ["path", "line", "column"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager
.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.completion(&uri, line, column);
let _ = client.did_close(&uri);
match result {
Ok(completion_result) => {
let items = if let Some(items) = completion_result.as_array() {
items.clone()
} else if let Some(arr) = completion_result.get("items").and_then(|v| v.as_array())
{
arr.clone()
} else {
Vec::new()
};
if items.is_empty() {
return Ok("No completions available at this position.".to_string());
}
let mut output = format!(
"{} completion suggestions at {}:{}:\n",
items.len(),
line + 1,
column + 1
);
for (i, item) in items.iter().enumerate().take(50) {
let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?");
let kind = match item
.get("kind")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0)
{
1 => "Text",
2 => "Method",
3 => "Function",
4 => "Constructor",
5 => "Field",
6 => "Variable",
7 => "Class",
8 => "Interface",
9 => "Module",
10 => "Property",
11 => "Unit",
12 => "Value",
13 => "Enum",
14 => "Keyword",
15 => "Snippet",
16 => "Color",
17 => "File",
18 => "Reference",
19 => "Folder",
20 => "EnumMember",
21 => "Constant",
22 => "Struct",
23 => "Event",
24 => "Operator",
25 => "TypeParameter",
_ => "Other",
};
let detail = item.get("detail").and_then(|d| d.as_str()).unwrap_or("");
let detail_str = if detail.is_empty() {
String::new()
} else {
format!(" - {detail}")
};
writeln!(output, " {}. [{}] {}{}", i + 1, kind, label, detail_str).unwrap();
}
if items.len() > 50 {
writeln!(output, " ... and {} more", items.len() - 50).unwrap();
}
Ok(output)
}
Err(e) => Err(e),
}
}
}
pub struct LspDefinition;
impl Tool for LspDefinition {
fn name(&self) -> &'static str {
"lsp_definition"
}
fn description(&self) -> &'static str {
"Go to definition: find the location where a symbol is defined. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
"description": "Path to the file (relative to workspace root)"
},
"line": {
"type": "integer",
"description": "Line number (0-based)"
},
"column": {
"type": "integer",
"description": "Column number (0-based)"
}
},
"required": ["path", "line", "column"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager
.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.goto_definition(&uri, line, column);
let _ = client.did_close(&uri);
match result {
Ok(def_result) => {
if def_result == Value::Null {
return Ok("No definition found at this position.".to_string());
}
let locations = if let Some(loc) = def_result.as_array() {
loc.clone()
} else {
vec![def_result.clone()]
};
if locations.is_empty() {
return Ok("No definition found.".to_string());
}
let mut output = String::from("Definition(s):\n");
for (i, loc) in locations.iter().enumerate().take(10) {
let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?");
let target_range = loc.get("range").or_else(|| loc.get("targetRange"));
let target_start = target_range.and_then(|r| r.get("start"));
let tl = target_start
.and_then(|s| s.get("line"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let tc = target_start
.and_then(|s| s.get("character"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri);
writeln!(output, " {}. {}:{}:{}", i + 1, path_str, tl + 1, tc + 1).unwrap();
}
if locations.len() > 10 {
writeln!(output, " ... and {} more", locations.len() - 10).unwrap();
}
Ok(output)
}
Err(e) => Err(e),
}
}
}
pub struct LspReferences;
impl Tool for LspReferences {
fn name(&self) -> &'static str {
"lsp_references"
}
fn description(&self) -> &'static str {
"Find all references to a symbol at a cursor position. \
`server` is optional — if omitted, the server is auto-detected from the file's extension."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"server": {
"type": "string",
"description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
},
"path": {
"type": "string",
"description": "Path to the file (relative to workspace root)"
},
"line": {
"type": "integer",
"description": "Line number (0-based)"
},
"column": {
"type": "integer",
"description": "Column number (0-based)"
}
},
"required": ["path", "line", "column"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager
.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.references(&uri, line, column);
let _ = client.did_close(&uri);
match result {
Ok(ref_result) => {
let locations = ref_result.as_array().cloned().unwrap_or_default();
if locations.is_empty() {
return Ok("No references found for this symbol.".to_string());
}
let mut output = format!("{} reference(s) found:\n", locations.len());
for (i, loc) in locations.iter().enumerate().take(50) {
let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?");
let range = loc.get("range").and_then(|r| r.get("start"));
let rl = range
.and_then(|s| s.get("line"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let rc = range
.and_then(|s| s.get("character"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri);
writeln!(output, " {}. {}:{}:{}", i + 1, path_str, rl + 1, rc + 1).unwrap();
}
if locations.len() > 50 {
writeln!(output, " ... and {} more references", locations.len() - 50).unwrap();
}
Ok(output)
}
Err(e) => Err(e),
}
}
}
pub struct LspDisconnect;
impl Tool for LspDisconnect {
fn name(&self) -> &'static str {
"lsp_disconnect"
}
fn description(&self) -> &'static str {
"Disconnect from a running LSP server and release its resources"
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the LSP server to disconnect"
}
},
"required": ["name"]
})
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: name"))?;
let mut manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
if manager.disconnect(name) {
Ok(format!("Disconnected from LSP server '{name}'"))
} else {
Err(anyhow!("LSP server '{name}' not found"))
}
}
}
/// Return the default file extensions associated with a language id.
///
/// Flow: pure `match` on `language_id` -> static slice of extension
/// strings (with leading dot). Returns an empty slice for unknown
/// languages, so callers can safely chain lookups without a special case.
///
/// Used by `lsp_connect` to auto-register extensions for a newly connected
/// server, and by `auto_detect_server` as a fallback when the manager's own
/// `extension_registry` has no entry yet.
fn known_extensions_for(language_id: &str) -> &[&'static str] {
match language_id {
"rust" => &[".rs"],
"typescript" => &[".ts", ".tsx", ".js", ".jsx"],
"go" => &[".go"],
"java" => &[".java"],
_ => &[],
}
}
/// Guess which connected LSP server should handle `path` based on its extension.
///
/// Flow: extract extension from `path` -> for each connected server, check
/// whether `known_extensions_for(server.language_id)` contains the extension
/// -> return the first match's `language_id`.
///
/// This is a fallback used only when the caller omits `server` and the file's
/// extension is not (yet) present in `LspManager::extension_registry` — e.g.
/// a server connected without an explicit `register_extensions` call. Returns
/// `None` if the path has no extension, the lock is poisoned, or no
/// connected server's language is known to use that extension.
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())?;
let dot_ext = format!(".{ext}");
if let Ok(mgr) = ctx.lsp_manager.lock() {
for s in &mgr.servers {
let exts = known_extensions_for(&s.language_id);
if exts.contains(&dot_ext.as_str()) {
return Some(s.language_id.clone());
}
}
}
None
}
/// Resolve the LSP server name to use for a tool call: explicit `server`
/// argument if present, otherwise auto-detected from `path`'s extension.
///
/// Flow: `args["server"]` present -> use it as-is. Otherwise -> try
/// registry lookup by delegating to `auto_detect_server`. If that also fails,
/// build a helpful error message
/// listing the currently connected servers (via `LspManager::list_servers`)
/// so the caller knows whether to connect one first.
///
/// Return: `Ok(server_name)` on success. `Err` only when no `server` was
/// given and auto-detection could not resolve one — never fails just
/// because the caller provided an explicit (possibly wrong) server name,
/// since downstream `get_client`/`get_language_id` calls report that error.
fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String> {
if let Some(server) = args.get("server").and_then(|v| v.as_str()) {
return Ok(server.to_string());
}
if let Some(name) = auto_detect_server(ctx, path) {
return Ok(name);
}
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map_or_else(|| "<none>".to_string(), |e| format!(".{e}"));
let available = ctx
.lsp_manager
.lock()
.ok()
.map(|mgr| {
mgr.list_servers()
.iter()
.map(|(lang, _)| lang.clone())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let available = if available.is_empty() {
"none".to_string()
} else {
available
};
Err(anyhow!(
"LSP server not found for extension '{ext}'. Use lsp_connect to connect one. Available servers: {available}"
))
}