Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/mcp/tools/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,104 @@ mod tests {
target.close();
}

#[tokio::test]
async fn graph_reader_selector_dispatch_accepts_unique_project_basename() {
let _env_lock = SELECTOR_ENV_LOCK.lock().await;
let dir = TempDir::new().unwrap();
let _env = SelectorEnv::new(dir.path());
let active_project = dir.path().join("active");
let target_project = dir.path().join("target");
fs::create_dir_all(active_project.join("src")).unwrap();
fs::create_dir_all(target_project.join("src")).unwrap();
fs::write(active_project.join("src/active.rs"), "pub fn active() {}\n").unwrap();
fs::write(target_project.join("src/target.rs"), "pub fn target() {}\n").unwrap();

let active = TraceDecay::init(&active_project).await.unwrap();
let target = TraceDecay::init(&target_project).await.unwrap();
target.index_all().await.unwrap();
let registry = GlobalDb::open().await.unwrap();

let result = handle_tool_call_with_registry(
&active,
"tracedecay_search",
json!({
"project_selector": {"path": "target"},
"query": "target",
"limit": 5,
}),
None,
None,
Some(&registry),
false,
)
.await
.unwrap();
let text = result.value["content"][0]["text"].as_str().unwrap();

assert!(
text.contains("target"),
"unique basename selector should return target graph results: {text}"
);
assert!(
!text.contains("active"),
"unique basename selector should not query the active graph: {text}"
);

active.checkpoint().await.unwrap();
target.checkpoint().await.unwrap();
active.close();
target.close();
}

#[tokio::test]
async fn graph_reader_selector_rejects_ambiguous_project_basename() {
let _env_lock = SELECTOR_ENV_LOCK.lock().await;
let dir = TempDir::new().unwrap();
let _env = SelectorEnv::new(dir.path());
let active_project = dir.path().join("active");
let first_target = dir.path().join("first").join("target");
let second_target = dir.path().join("second").join("target");
fs::create_dir_all(active_project.join("src")).unwrap();
fs::create_dir_all(first_target.join("src")).unwrap();
fs::create_dir_all(second_target.join("src")).unwrap();
fs::write(active_project.join("src/active.rs"), "pub fn active() {}\n").unwrap();
fs::write(first_target.join("src/first.rs"), "pub fn first() {}\n").unwrap();
fs::write(second_target.join("src/second.rs"), "pub fn second() {}\n").unwrap();

let active = TraceDecay::init(&active_project).await.unwrap();
let first = TraceDecay::init(&first_target).await.unwrap();
let second = TraceDecay::init(&second_target).await.unwrap();
first.index_all().await.unwrap();
second.index_all().await.unwrap();
let registry = GlobalDb::open().await.unwrap();

let err = handle_tool_call_with_registry(
&active,
"tracedecay_search",
json!({
"project_selector": {"path": "target"},
"query": "target",
}),
None,
None,
Some(&registry),
false,
)
.await
.unwrap_err();
assert!(
format!("{err}").contains("registered project not found for selector"),
"ambiguous basename selector should be rejected: {err}"
);

active.checkpoint().await.unwrap();
first.checkpoint().await.unwrap();
second.checkpoint().await.unwrap();
active.close();
first.close();
second.close();
}

#[tokio::test]
async fn unsupported_selector_tool_rejects_explicit_project_selector() {
let _env_lock = SELECTOR_ENV_LOCK.lock().await;
Expand Down
142 changes: 129 additions & 13 deletions src/mcp/tools/handlers/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::path::{Component, Path, PathBuf};
use serde_json::Value;

use crate::errors::{Result, TraceDecayError};
use crate::global_db::{GlobalDb, ProjectRegistryContext};
use crate::global_db::{CodeProjectRecord, GlobalDb, ProjectRegistryContext};

/// Extracts the `node_id` parameter from tool arguments, accepting `id` as a
/// fallback alias. LLMs occasionally shorten `node_id` to `id`; this avoids a
Expand Down Expand Up @@ -194,27 +194,97 @@ pub(super) async fn project_registry_context(
});
}
};
let context = if let Some(project_id) = project_id {
db.project_registry_context_by_id(project_id).await
} else if let Some(project_path) = project_path {
db.project_registry_context_by_alias(Path::new(project_path))
.await
} else {
return Ok(None);
};
let context = resolve_project_registry_context(db, project_id, project_path).await;

context
.ok_or_else(|| TraceDecayError::Config {
message: "registered project not found for selector".to_string(),
})
.ok_or_else(|| unresolved_project_selector_error(project_id, project_path))
.map(Some)
}

async fn resolve_project_registry_context(
db: &GlobalDb,
project_id: Option<&str>,
project_path: Option<&str>,
) -> Option<ProjectRegistryContext> {
if let Some(project_id) = project_id {
return db.project_registry_context_by_id(project_id).await;
}
let project_path = project_path?;
if let Some(context) = db
.project_registry_context_by_alias(Path::new(project_path))
.await
{
return Some(context);
}
let basename = bare_project_name(project_path)?;
unique_project_basename_context(db, basename).await
}

async fn unique_project_basename_context(
db: &GlobalDb,
basename: &str,
) -> Option<ProjectRegistryContext> {
let mut matching_ids = Vec::new();
for project in db.search_code_projects(basename, usize::MAX).await {
if !project_basename_matches(&project, basename)
|| matching_ids.contains(&project.project_id)
{
continue;
}
matching_ids.push(project.project_id);
if matching_ids.len() > 1 {
return None;
}
}
let project_id = matching_ids.into_iter().next()?;
db.project_registry_context_by_id(&project_id).await
}

fn bare_project_name(value: &str) -> Option<&str> {
let mut components = Path::new(value).components();
let first = components.next()?;
if components.next().is_some() {
return None;
}
match first {
Component::Normal(name) => name.to_str().filter(|name| !name.is_empty()),
_ => None,
}
}

fn project_basename_matches(project: &CodeProjectRecord, basename: &str) -> bool {
[
project.display_root.as_str(),
project.canonical_root.as_str(),
]
.into_iter()
.filter_map(|root| Path::new(root).file_name())
.any(|name| name == basename)
}

fn unresolved_project_selector_error(
project_id: Option<&str>,
project_path: Option<&str>,
) -> TraceDecayError {
let selector = project_id
.map(|value| format!("project_id={value}"))
.or_else(|| project_path.map(|value| format!("project_path={value}")))
.unwrap_or_else(|| "empty selector".to_string());
TraceDecayError::Config {
message: format!(
"registered project not found for selector ({selector}); run tracedecay_project_search to find the registered project_id or full project_path"
),
}
}

#[cfg(test)]
mod tests {
use serde_json::json;
use tempfile::TempDir;

use super::{require_node_id, string_array_values};
use crate::global_db::GlobalDb;

use super::{require_node_id, string_array_values, unique_project_basename_context};

#[test]
fn test_require_node_id_canonical() {
Expand Down Expand Up @@ -254,4 +324,50 @@ mod tests {
assert!(string_array_values(&args, "missing").is_empty());
assert!(string_array_values(&args, "not_array").is_empty());
}

#[tokio::test]
async fn unique_project_basename_context_scans_past_first_search_page(
) -> Result<(), Box<dyn std::error::Error>> {
let dir = TempDir::new()?;
let db = GlobalDb::open_at(&dir.path().join("global.db"))
.await
.ok_or_else(|| std::io::Error::other("failed to open test global db"))?;

let first_exact = dir.path().join("first").join("target");
std::fs::create_dir_all(&first_exact)?;
db.upsert_code_project("z_exact_old", &first_exact, None, None, Some("main"))
.await
.ok_or_else(|| std::io::Error::other("failed to insert first exact project"))?;

for index in 0..100 {
let root = dir
.path()
.join("noise")
.join(format!("target-noise-{index:03}"));
std::fs::create_dir_all(&root)?;
db.upsert_code_project(
&format!("n_noise_{index:03}"),
&root,
None,
None,
Some("main"),
)
.await
.ok_or_else(|| std::io::Error::other("failed to insert noise project"))?;
}

let second_exact = dir.path().join("second").join("target");
std::fs::create_dir_all(&second_exact)?;
db.upsert_code_project("a_exact_new", &second_exact, None, None, Some("main"))
.await
.ok_or_else(|| std::io::Error::other("failed to insert second exact project"))?;

assert!(
unique_project_basename_context(&db, "target")
.await
.is_none(),
"duplicate exact basenames must fail closed even when one match falls outside the first search page"
);
Ok(())
}
}
Loading