use crate::server::helpers::{
format_degraded_notice, millis_to_u64, pathfinder_to_error_data, serialize_metadata,
};
use crate::server::types::{
default_max_tokens, GetRepoMapParams, LspCapabilities, RepoCapabilities,
};
use crate::server::PathfinderServer;
use pathfinder_common::types::DegradedReason;
use rmcp::model::{CallToolResult, ErrorData};
use std::path::Path;
use std::sync::Arc;
async fn count_source_files(root: &Path) -> usize {
let mut count = 0;
let extensions: [&str; 38] = [
"rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "kt", "swift", "cpp", "c", "h", "cs",
"rb", "php", "scala", "clj", "ex", "exs", "erl", "hs", "ml", "m", "nim", "pl", "pm", "r",
"sh", "lua", "dart", "fs", "fsi", "fsx", "zig", "v", "svelte", "vue",
];
let mut dirs_to_visit = vec![root.to_path_buf()];
while let Some(dir_path) = dirs_to_visit.pop() {
let Ok(read_dir) = tokio::fs::read_dir(&dir_path).await else {
continue;
};
let mut entries_stream = read_dir;
loop {
match entries_stream.next_entry().await {
Ok(Some(entry)) => {
if let Ok(file_type) = entry.file_type().await {
let path = entry.path();
if file_type.is_file() {
let ext = path.extension().and_then(|e| e.to_str());
if ext.is_some_and(|e| extensions.contains(&e)) {
count += 1;
}
} else if file_type.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !matches!(
dir_name,
"node_modules"
| "target"
| "vendor"
| ".git"
| "dist"
| "build"
| "out"
| ".next"
| ".venv"
| "venv"
| "env"
) {
dirs_to_visit.push(path);
}
}
}
}
Ok(None) => break,
Err(_) => {}
}
}
}
count
}
fn derive_lsp_status(
capability_status: &std::collections::HashMap<String, pathfinder_lsp::types::LspLanguageStatus>,
) -> Option<std::collections::HashMap<String, String>> {
if capability_status.is_empty() {
return None;
}
let map = capability_status
.iter()
.map(|(lang, status)| {
let s = if status.navigation_ready == Some(true) {
"ready"
} else if status.navigation_ready == Some(false)
|| status.indexing_complete == Some(false)
{
"warming_up"
} else if status.uptime_seconds.is_some() {
"starting"
} else {
"unavailable"
};
(lang.clone(), s.to_owned())
})
.collect();
Some(map)
}
impl PathfinderServer {
async fn empty_changes_response(
&self,
changed_since_ref: &str,
) -> Result<CallToolResult, ErrorData> {
let capability_status = self.lawyer.capability_status().await;
let lsp_status = derive_lsp_status(&capability_status);
let metadata = crate::server::types::GetRepoMapMetadata {
tech_stack: vec![],
files_scanned: 0,
files_truncated: 0,
truncated_paths: vec![],
files_in_scope: 0,
coverage_percent: 100,
version_hashes: std::collections::HashMap::new(),
visibility_degraded: None,
degraded: false,
degraded_reason: None,
actionable_guidance: None,
capabilities: RepoCapabilities {
search: true,
lsp: LspCapabilities {
supported: true,
per_language: capability_status,
},
},
max_tokens_used: 0,
lsp_status,
duration_ms: None,
};
let message = format!(
"No files changed since '{changed_since_ref}'. \
The repository is unchanged relative to that ref.\n\
To see the full repository skeleton, call get_repo_map without the \
changed_since parameter."
);
let mut res = CallToolResult::success(vec![rmcp::model::Content::text(message)]);
res.structured_content = serialize_metadata(&metadata);
Ok(res)
}
#[expect(
clippy::too_many_lines,
reason = "Linear orchestration pipeline: sandbox → git filter → auto-scale → tree-sitter → LSP pre-warm → metadata assembly. Extraction would obscure the sequential flow."
)]
pub(crate) async fn get_repo_map_impl(
&self,
params: GetRepoMapParams,
) -> Result<CallToolResult, ErrorData> {
let start = std::time::Instant::now();
tracing::info!(tool = "get_repo_map", path = %params.path, "get_repo_map: start");
let target_path = Path::new(¶ms.path);
if let Err(e) = self.sandbox.check(target_path) {
tracing::warn!(tool = "get_repo_map", path = %params.path, error = %e, "get_repo_map: access denied");
return Err(pathfinder_to_error_data(&e));
}
let mut degraded = false;
let mut degraded_reason = None;
let mut changed_files = None;
if !params.changed_since.is_empty() {
match pathfinder_common::git::get_changed_files_since(
&pathfinder_common::git::SystemGit,
self.workspace_root.path(),
¶ms.changed_since,
)
.await
{
Ok(files) => {
if files.is_empty() {
return self.empty_changes_response(¶ms.changed_since).await;
}
changed_files = Some(files);
}
Err(e) => {
tracing::warn!(error = %e, "get_repo_map: fallback to full map (git failed)");
degraded = true;
degraded_reason = Some(DegradedReason::GitError);
}
}
}
let ts_start = std::time::Instant::now();
let visibility_str = match params.visibility {
pathfinder_common::types::Visibility::Public => "public",
pathfinder_common::types::Visibility::All => "all",
};
let effective_max_tokens = if params.max_tokens == default_max_tokens() {
let source_file_count = count_source_files(self.workspace_root.path()).await;
if source_file_count > 20 {
let scaled = (u32::try_from(source_file_count).unwrap_or(u32::MAX) * 800)
.clamp(16_000, 48_000);
tracing::info!(
tool = "get_repo_map",
source_file_count,
auto_scaled_tokens = scaled,
requested_tokens = params.max_tokens,
"auto-scaling max_tokens for large project"
);
scaled
} else {
params.max_tokens
}
} else {
params.max_tokens
};
let max_tokens = effective_max_tokens.clamp(500, 100_000);
let config = pathfinder_treesitter::repo_map::SkeletonConfig::new(
max_tokens,
params.depth,
visibility_str,
params.max_tokens_per_file,
)
.with_changed_files(changed_files)
.with_include_extensions(params.include_extensions)
.with_exclude_extensions(params.exclude_extensions)
.with_include_tests(params.include_tests);
let result = match self
.surgeon
.generate_skeleton(self.workspace_root.path(), target_path, &config)
.await
{
Ok(r) => r,
Err(e) => {
return Err(crate::server::helpers::treesitter_error_to_error_data(e));
}
};
let tree_sitter_ms = ts_start.elapsed().as_millis();
tracing::info!(
tool = "get_repo_map",
path = %params.path,
tree_sitter_ms,
duration_ms = start.elapsed().as_millis(),
files_scanned = result.files_scanned,
files_truncated = result.files_truncated,
engines_used = "treesitter",
"get_repo_map: complete"
);
if !result.tech_stack.is_empty() {
let lawyer = Arc::clone(&self.lawyer);
let languages = result.tech_stack.clone();
tokio::spawn(async move {
lawyer.warm_start_for_languages_and_track(&languages);
tracing::info!(
"PATCH-004: get_repo_map triggered warm_start_and_track for {} languages",
languages.len()
);
});
}
let capability_status = self.lawyer.capability_status().await;
let lsp_status = derive_lsp_status(&capability_status);
let duration_ms = start.elapsed().as_millis();
let metadata = crate::server::types::GetRepoMapMetadata {
tech_stack: result.tech_stack,
files_scanned: result.files_scanned,
files_truncated: result.files_truncated,
truncated_paths: result.truncated_paths,
files_in_scope: result.files_in_scope,
coverage_percent: result.coverage_percent,
version_hashes: result.version_hashes,
visibility_degraded: None,
degraded,
degraded_reason,
actionable_guidance: degraded_reason.as_ref().map(DegradedReason::guidance),
capabilities: RepoCapabilities {
search: true,
lsp: LspCapabilities {
supported: true,
per_language: capability_status,
},
},
max_tokens_used: max_tokens,
lsp_status,
duration_ms: Some(millis_to_u64(duration_ms)),
};
let text = if degraded {
let notice = degraded_reason
.as_ref()
.map_or_else(|| "DEGRADED (unknown)".to_owned(), format_degraded_notice);
format!(
"{notice}\n{}\n[completed in {duration_ms}ms]",
result.skeleton
)
} else {
format!("{}\n[completed in {duration_ms}ms]", result.skeleton)
};
let mut res = CallToolResult::success(vec![rmcp::model::Content::text(text)]);
res.structured_content = serialize_metadata(&metadata);
Ok(res)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use crate::server::types::GetRepoMapParams;
use pathfinder_common::config::PathfinderConfig;
use pathfinder_common::sandbox::Sandbox;
use pathfinder_common::types::{Visibility, WorkspaceRoot};
use pathfinder_search::MockScout;
use pathfinder_treesitter::mock::MockSurgeon;
use pathfinder_treesitter::repo_map::RepoMapResult;
use pathfinder_treesitter::SurgeonError;
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::tempdir;
fn default_params() -> GetRepoMapParams {
GetRepoMapParams {
path: ".".to_owned(),
changed_since: String::new(),
max_tokens: 16_000,
max_tokens_per_file: 2_000,
depth: 5,
visibility: Visibility::Public,
include_extensions: vec![],
exclude_extensions: vec![],
include_tests: true,
}
}
fn make_server(surgeon: MockSurgeon) -> (crate::server::PathfinderServer, tempfile::TempDir) {
let ws_dir = tempdir().expect("tempdir");
let ws = WorkspaceRoot::new(ws_dir.path()).expect("workspace");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
let server = crate::server::PathfinderServer::with_all_engines(
ws,
config,
sandbox,
Arc::new(MockScout::default()),
Arc::new(surgeon),
Arc::new(pathfinder_lsp::NoOpLawyer),
);
(server, ws_dir)
}
fn ok_result() -> RepoMapResult {
RepoMapResult {
skeleton: "# skeleton".to_owned(),
tech_stack: vec!["rust".to_owned()],
files_scanned: 3,
files_truncated: 0,
truncated_paths: vec![],
files_in_scope: 3,
coverage_percent: 100,
version_hashes: HashMap::new(),
}
}
#[tokio::test]
async fn test_get_repo_map_returns_skeleton() {
let surgeon = MockSurgeon::default();
surgeon
.generate_skeleton_results
.lock()
.unwrap()
.push(Ok(ok_result()));
let (server, _dir) = make_server(surgeon);
let result = server.get_repo_map_impl(default_params()).await;
assert!(result.is_ok(), "should succeed: {result:?}");
let tool_result = result.unwrap();
let text = tool_result
.content
.first()
.and_then(|c| {
if let rmcp::model::RawContent::Text(t) = &c.raw {
Some(t.text.clone())
} else {
None
}
})
.unwrap_or_default();
assert!(text.contains("skeleton"), "skeleton text should be present");
}
#[tokio::test]
async fn test_get_repo_map_rejects_sandbox_denied_path() {
let (server, _dir) = make_server(MockSurgeon::default());
let mut params = default_params();
params.path = ".git/HEAD".to_owned();
let result = server.get_repo_map_impl(params).await;
assert!(result.is_err(), "sandbox should deny .git paths");
let err = result.unwrap_err();
let code = err
.data
.as_ref()
.and_then(|d| d.get("error"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(code, "ACCESS_DENIED");
}
#[tokio::test]
async fn test_get_repo_map_propagates_surgeon_error() {
let surgeon = MockSurgeon::default();
surgeon
.generate_skeleton_results
.lock()
.unwrap()
.push(Err(SurgeonError::Io(std::io::Error::other("disk full"))));
let (server, _dir) = make_server(surgeon);
let result = server.get_repo_map_impl(default_params()).await;
assert!(result.is_err(), "surgeon error should propagate");
}
#[tokio::test]
async fn test_get_repo_map_changed_since_empty_returns_early() {
let ws_dir = tempdir().expect("tempdir");
let ws = WorkspaceRoot::new(ws_dir.path()).expect("workspace");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(ws_dir.path())
.status()
.expect("git init");
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "t@t.t")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "t@t.t")
.current_dir(ws_dir.path())
.status()
.expect("git commit");
let config = PathfinderConfig::default();
let sandbox = Sandbox::new(ws.path(), &config.sandbox);
let server = crate::server::PathfinderServer::with_all_engines(
ws,
config,
sandbox,
Arc::new(MockScout::default()),
Arc::new(MockSurgeon::default()), Arc::new(pathfinder_lsp::NoOpLawyer),
);
let mut params = default_params();
params.changed_since = "HEAD".to_owned();
let result = server.get_repo_map_impl(params).await;
assert!(result.is_ok(), "empty changed_since should succeed");
let tool_result = result.unwrap();
let text = tool_result
.content
.first()
.and_then(|c| {
if let rmcp::model::RawContent::Text(t) = &c.raw {
Some(t.text.clone())
} else {
None
}
})
.unwrap_or_default();
assert!(
text.contains("No files changed"),
"should return empty-changes message, got: {text}"
);
}
#[tokio::test]
async fn test_get_repo_map_changed_since_git_failure_falls_back() {
let surgeon = MockSurgeon::default();
surgeon
.generate_skeleton_results
.lock()
.unwrap()
.push(Ok(ok_result()));
let (server, _dir) = make_server(surgeon);
let mut params = default_params();
params.changed_since = "nonexistent-ref-xyzzy".to_owned();
let result = server.get_repo_map_impl(params).await;
assert!(
result.is_ok(),
"git failure should fall back to full map: {result:?}"
);
let tool_result = result.unwrap();
let meta = tool_result.structured_content.as_ref().unwrap();
assert_eq!(
meta.get("degraded").and_then(serde_json::Value::as_bool),
Some(true),
"degraded flag should be set on git failure"
);
}
#[tokio::test]
async fn test_get_repo_map_triggers_lt4_prewarm() {
let mut result = ok_result();
result.tech_stack = vec!["rust".to_owned(), "go".to_owned()];
let surgeon = MockSurgeon::default();
surgeon
.generate_skeleton_results
.lock()
.unwrap()
.push(Ok(result));
let (server, _dir) = make_server(surgeon);
let result = server.get_repo_map_impl(default_params()).await;
assert!(result.is_ok(), "get_repo_map should succeed: {result:?}");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
#[test]
fn test_derive_lsp_status_empty_map_returns_none() {
let empty: std::collections::HashMap<String, pathfinder_lsp::types::LspLanguageStatus> =
std::collections::HashMap::new();
assert!(
super::derive_lsp_status(&empty).is_none(),
"empty capability map must produce None lsp_status"
);
}
#[allow(clippy::too_many_lines)]
#[test]
fn test_derive_lsp_status_correct_status_strings() {
use pathfinder_lsp::types::LspLanguageStatus;
let mut map = std::collections::HashMap::new();
map.insert(
"rust".to_owned(),
LspLanguageStatus {
validation: false,
reason: String::new(),
navigation_ready: Some(true),
indexing_complete: None,
uptime_seconds: Some(30),
diagnostics_strategy: None,
supports_definition: None,
supports_call_hierarchy: None,
supports_diagnostics: None,
supports_formatting: None,
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
registrations_received: None,
},
);
map.insert(
"csharp".to_owned(),
LspLanguageStatus {
validation: false,
reason: String::new(),
navigation_ready: Some(false),
indexing_complete: None,
uptime_seconds: Some(15),
diagnostics_strategy: None,
supports_definition: None,
supports_call_hierarchy: None,
supports_diagnostics: None,
supports_formatting: None,
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
registrations_received: None,
},
);
map.insert(
"go".to_owned(),
LspLanguageStatus {
validation: false,
reason: String::new(),
navigation_ready: None,
indexing_complete: Some(false),
uptime_seconds: Some(10),
diagnostics_strategy: None,
supports_definition: None,
supports_call_hierarchy: None,
supports_diagnostics: None,
supports_formatting: None,
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
registrations_received: None,
},
);
map.insert(
"typescript".to_owned(),
LspLanguageStatus {
validation: false,
reason: String::new(),
navigation_ready: None,
indexing_complete: None,
uptime_seconds: Some(5),
diagnostics_strategy: None,
supports_definition: None,
supports_call_hierarchy: None,
supports_diagnostics: None,
supports_formatting: None,
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
registrations_received: None,
},
);
map.insert(
"python".to_owned(),
LspLanguageStatus {
validation: false,
reason: String::new(),
navigation_ready: None,
indexing_complete: None,
uptime_seconds: None,
diagnostics_strategy: None,
supports_definition: None,
supports_call_hierarchy: None,
supports_diagnostics: None,
supports_formatting: None,
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
registrations_received: None,
},
);
let result = super::derive_lsp_status(&map).expect("non-empty map must return Some");
assert_eq!(result.get("rust").map(String::as_str), Some("ready"));
assert_eq!(result.get("csharp").map(String::as_str), Some("warming_up"));
assert_eq!(result.get("go").map(String::as_str), Some("warming_up"));
assert_eq!(
result.get("typescript").map(String::as_str),
Some("starting")
);
assert_eq!(
result.get("python").map(String::as_str),
Some("unavailable")
);
}
#[tokio::test]
async fn test_get_repo_map_no_prewarm_when_tech_stack_empty() {
let mut result = ok_result();
result.tech_stack = vec![];
let surgeon = MockSurgeon::default();
surgeon
.generate_skeleton_results
.lock()
.unwrap()
.push(Ok(result));
let (server, _dir) = make_server(surgeon);
let result = server.get_repo_map_impl(default_params()).await;
assert!(result.is_ok(), "get_repo_map should succeed: {result:?}");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
#[tokio::test]
async fn test_get_repo_map_auto_scaling_for_large_project() {
let surgeon = MockSurgeon::default();
surgeon
.generate_skeleton_results
.lock()
.unwrap()
.push(Ok(ok_result()));
let (server, ws_dir) = make_server(surgeon);
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
for i in 0..25 {
std::fs::write(
ws_dir.path().join(format!("src/file{i}.rs")),
format!("fn func_{i}() {{}}"),
)
.unwrap();
}
let mut params = default_params();
params.max_tokens = 16_000;
let result = server.get_repo_map_impl(params).await;
assert!(result.is_ok(), "get_repo_map should succeed: {result:?}");
}
}