diff --git a/.please/docs/references/semble.md b/.please/docs/references/semble.md index 3c1fcc1..1417a05 100644 --- a/.please/docs/references/semble.md +++ b/.please/docs/references/semble.md @@ -327,11 +327,21 @@ Clean two-layer split: - **`csp::mcp`** (lib) — the unit-tested tool **core**: `search` / `find_related` handler logic, in-process LRU `IndexCache` (`CACHE_MAX_SIZE = 10`, `Arc` so indexes are `Send` across tasks), `_get_index` with git-transport guards. +- **Per-call `content`** (upstream #247): both tools take `content: Option` + (`code | docs | config | all`); `resolve_content_selection` maps `None` → the server's + `--content` default and `all` → every `ContentType` (upstream `_resolve_content_selection`). + `IndexCache` is keyed by `CacheKey { source, content }` where `content` is normalized to enum + order and de-duplicated (upstream `_CacheKey = (source_key, tuple[ContentType, ...])`), so one + repo searched as `code` and as `docs` holds two independent session entries; `get` / `evict` + and fingerprint revalidation all take the content slice. No on-disk change was needed — + `indexing/cache.rs::resolve_cache_dir` already hashes `sourceId + content + ref` (upstream + instead added `index-` sibling dirs in `cache.py`). - **`csp` bin `mcp_server`** (bin) — **rmcp 1.7** stdio wiring: `CspMcpServer` with `#[tool_router]` + `#[tool]` async `search`/`find_related`, `#[tool_handler(router = self.tool_router)]` (routes through the stored field; the default `Self::tool_router()` would rebuild per call and trip clippy `dead_code`). `run_mcp(path, ref, content)` serves on a tokio - runtime. Verified on the wire (initialize / tools/list / tools/call). + runtime with `content` as the per-call default. Verified on the wire (initialize / tools/list / + tools/call). ### 4.17 `csp/src/bin/csp/main.rs` — CLI (clap) diff --git a/README.ko.md b/README.ko.md index 2fc7437..2ff2e04 100644 --- a/README.ko.md +++ b/README.ko.md @@ -357,6 +357,8 @@ args = [ 두 도구 모두 결과당 반환 코드를 제한하는 `max_snippet_lines`를 받습니다. 기본값은 `10`으로, 시그니처+본문 앞부분 미리보기라 에이전트가 위치를 싸게 확인한 뒤 전체 맥락은 파일로 이동해 봅니다. 위치만 필요하면 `0`, 미리보기로 부족하면 `null`로 전체 청크를 받습니다. +또한 두 도구 모두 호출 단위로 검색 대상을 고르는 `content`(`code`, `docs`, `config`, `all`)를 받습니다. 예를 들어 서버가 코드만 인덱싱하는 저장소에서 가이드 문서를 찾고 싶다면 `content: "docs"`를 넘기면 됩니다. 생략하면 아래의 서버 설정 값을 따르며, 서로 다른 `content` 선택은 세션 안에서 각각 별도로 인덱싱·캐시됩니다. + 기본적으로 MCP 서버는 코드 파일만 인덱싱합니다. 문서/설정/전체를 함께 인덱싱하려면 명령에 `--content docs`, `--content config`, `--content all` 또는 조합(예: `--content code docs`)을 추가하세요. 예를 들어 Claude Code에서는 `claude mcp add csp -s user -- bunx @pleaseai/csp mcp --content all`. ## 서브 에이전트 설정 diff --git a/README.md b/README.md index 0dfbd70..c06d21d 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,8 @@ Add to `~/.config/zed/settings.json` (or `.zed/settings.json` in your project): Both tools accept `max_snippet_lines` to cap the code returned per result. It defaults to `10` — a signature-plus-first-lines preview that lets an agent confirm a location cheaply, then navigate to the file for full context. Pass `0` for the location only, or `null` for the full chunk when the preview lacks context. +Both tools also accept `content` (`code`, `docs`, `config`, or `all`) to choose what a single call searches, e.g. `content: "docs"` to look up a guide in a repo the server otherwise indexes as code. It defaults to the server's configured content (below). Each distinct content selection is indexed and cached separately for the session. + By default the MCP server indexes only code files. To also index documentation, config, or everything, append `--content docs`, `--content config`, or `--content all` to the server command, or a combination, e.g. `--content code docs`. For example, in Claude Code: `claude mcp add csp -s user -- bunx @pleaseai/csp mcp --content all`. ## Sub-agent setup diff --git a/crates/csp/src/bin/csp/mcp_server.rs b/crates/csp/src/bin/csp/mcp_server.rs index 44d8c61..e12707c 100644 --- a/crates/csp/src/bin/csp/mcp_server.rs +++ b/crates/csp/src/bin/csp/mcp_server.rs @@ -15,7 +15,10 @@ use rmcp::transport::stdio; use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler, ServiceExt}; use tokio::sync::Mutex; -use csp::mcp::{find_related_tool, search_tool, IndexCache, SERVER_INSTRUCTIONS}; +use csp::mcp::{ + find_related_tool, resolve_content_selection, search_tool, ContentSelection, IndexCache, + SERVER_INSTRUCTIONS, +}; use csp::stats::default_stats_file; use csp::types::ContentType; use csp::utils::resolve_snippet_lines; @@ -41,6 +44,9 @@ pub struct SearchParams { /// `null` for the full chunk when the snippet lacks context. #[serde(default = "default_max_snippet_lines")] pub max_snippet_lines: Option, + /// Content to search: `code`, `docs`, `config`, or `all`. Defaults to the + /// MCP server's configured content (`--content`). + pub content: Option, } /// Parameters for the `find_related` tool. @@ -58,14 +64,21 @@ pub struct FindRelatedParams { /// 0 = location only. Pass `null` for the full chunk. #[serde(default = "default_max_snippet_lines")] pub max_snippet_lines: Option, + /// Content containing the related file: `code`, `docs`, `config`, or + /// `all`. Defaults to the MCP server's configured content (`--content`). + pub content: Option, } -/// MCP server holding the session index cache and the default source. +/// MCP server holding the session index cache, the default source, and the +/// default content selection. #[derive(Clone)] pub struct CspMcpServer { cache: Arc>, default_source: Option, default_ref: Option, + /// Content types indexed when a tool call omits `content` (the `--content` + /// flag; code-only by default). + default_content: Vec, /// Where token-savings telemetry is appended; `None` disables recording /// (used by tests so they don't touch the real `~/.csp/savings.jsonl`). stats_file: Option, @@ -80,9 +93,10 @@ impl CspMcpServer { content: Vec, ) -> Self { Self { - cache: Arc::new(Mutex::new(IndexCache::new(content))), + cache: Arc::new(Mutex::new(IndexCache::new())), default_source, default_ref, + default_content: content, stats_file: Some(default_stats_file()), tool_router: Self::tool_router(), } @@ -95,6 +109,7 @@ impl CspMcpServer { &self, Parameters(p): Parameters, ) -> Result { + let content = resolve_content_selection(p.content, &self.default_content); let mut cache = self.cache.lock().await; let out = search_tool( &mut cache, @@ -102,6 +117,7 @@ impl CspMcpServer { self.default_ref.as_deref(), &p.query, p.repo.as_deref(), + &content, p.top_k.unwrap_or(5) as usize, resolve_snippet_lines(p.max_snippet_lines), self.stats_file.as_deref(), @@ -116,6 +132,7 @@ impl CspMcpServer { &self, Parameters(p): Parameters, ) -> Result { + let content = resolve_content_selection(p.content, &self.default_content); let mut cache = self.cache.lock().await; let out = find_related_tool( &mut cache, @@ -124,6 +141,7 @@ impl CspMcpServer { &p.file_path, p.line, p.repo.as_deref(), + &content, p.top_k.unwrap_or(5) as usize, resolve_snippet_lines(p.max_snippet_lines), self.stats_file.as_deref(), @@ -147,7 +165,8 @@ impl ServerHandler for CspMcpServer { /// /// `default_source` is the source indexed when a tool call omits `repo`; /// `default_ref` pins the git revision for that default source (the `--ref` -/// flag); `content` is the content-type filter applied when building indexes. +/// flag); `content` is the content-type filter applied when a tool call omits +/// its own `content` selection. pub fn run_mcp( default_source: Option, default_ref: Option, @@ -177,6 +196,7 @@ mod tests { assert_eq!(minimal.query, "greet"); assert!(minimal.repo.is_none()); assert!(minimal.top_k.is_none()); + assert!(minimal.content.is_none()); // Absent max_snippet_lines → the MCP default of 10. assert_eq!(minimal.max_snippet_lines, Some(10)); @@ -214,6 +234,81 @@ mod tests { assert_eq!(p.line, 1); assert!(p.repo.is_none()); assert!(p.top_k.is_none()); + assert!(p.content.is_none()); + } + + #[test] + fn params_accept_content_selection_and_reject_unknown() { + let s: SearchParams = + serde_json::from_value(serde_json::json!({ "query": "q", "content": "docs" })).unwrap(); + assert_eq!(s.content, Some(ContentSelection::Docs)); + let f: FindRelatedParams = serde_json::from_value(serde_json::json!({ + "file_path": "a.ts", + "line": 1, + "content": "all" + })) + .unwrap(); + assert_eq!(f.content, Some(ContentSelection::All)); + // Only the four documented values are accepted (no `tests`, no casing). + assert!(serde_json::from_value::( + serde_json::json!({ "query": "q", "content": "tests" }) + ) + .is_err()); + assert!(serde_json::from_value::( + serde_json::json!({ "query": "q", "content": "Docs" }) + ) + .is_err()); + } + + /// Resolve a schema node to the one carrying `enum`: either the node + /// itself, a `$ref` into the document's `$defs`, or the first `anyOf` + /// branch that resolves (schemars wraps `Option` as + /// `anyOf: [{ $ref }, { type: null }]`). + fn enum_values<'a>( + doc: &'a serde_json::Value, + node: &'a serde_json::Value, + ) -> Option<&'a Vec> { + if let Some(values) = node.get("enum").and_then(|e| e.as_array()) { + return Some(values); + } + if let Some(reference) = node.get("$ref").and_then(|r| r.as_str()) { + let name = reference.strip_prefix("#/$defs/")?; + return enum_values(doc, doc.get("$defs")?.get(name)?); + } + node.get("anyOf")? + .as_array()? + .iter() + .find_map(|branch| enum_values(doc, branch)) + } + + #[test] + fn tool_schemas_advertise_content_enum() { + // The wire schema clients see must list the content selection so an + // agent can discover the parameter without reading the README. Compare + // the enum values structurally (through the `$ref`), not by substring. + let expected: std::collections::BTreeSet<&str> = + ["code", "docs", "config", "all"].into_iter().collect(); + let tools = CspMcpServer::tool_router().list_all(); + for tool in tools { + let schema = serde_json::to_value(&tool.input_schema).unwrap(); + let content = &schema["properties"]["content"]; + assert!( + content.is_object(), + "{} schema lacks `content`: {schema}", + tool.name + ); + let values = enum_values(&schema, content) + .unwrap_or_else(|| panic!("{} `content` has no enum: {content}", tool.name)); + let actual: std::collections::BTreeSet<&str> = values + .iter() + .map(|v| { + v.as_str().unwrap_or_else(|| { + panic!("{} `content` enum has non-string value: {v}", tool.name) + }) + }) + .collect(); + assert_eq!(actual, expected, "{} `content` enum", tool.name); + } } #[test] @@ -251,6 +346,7 @@ mod tests { repo: None, top_k: Some(5), max_snippet_lines: None, + content: None, })) .await .unwrap(); @@ -264,6 +360,71 @@ mod tests { assert!(value.get("results").is_some() || value.get("error").is_some()); } + /// Extract the text payload of a tool result as parsed JSON. + fn payload(result: &CallToolResult) -> serde_json::Value { + let text = match &result.content[0].raw { + rmcp::model::RawContent::Text(t) => t.text.clone(), + _ => panic!("expected text content"), + }; + serde_json::from_str(&text).unwrap() + } + + /// File paths of every result in a search payload. + fn result_paths(value: &serde_json::Value) -> Vec { + value["results"] + .as_array() + .map(|r| { + r.iter() + .map(|e| e["file_path"].as_str().unwrap().to_string()) + .collect() + }) + .unwrap_or_default() + } + + #[tokio::test] + async fn search_tool_call_honors_per_call_content() { + // A code-only server (the default) whose repo also holds a doc file. + let dir = sample_source(); + fs::write( + dir.path().join("README.md"), + "# Guide\n\nHow to greet a user by name from the command line.\n", + ) + .unwrap(); + let mut server = CspMcpServer::new( + Some(dir.path().to_string_lossy().into_owned()), + None, + vec![ContentType::Code], + ); + server.stats_file = None; + let call = |content: Option| { + server.search(Parameters(SearchParams { + query: "greet".to_string(), + repo: None, + top_k: Some(5), + max_snippet_lines: Some(0), + content, + })) + }; + + // Omitted → the server default (code): only sample.ts is indexed. + let paths = result_paths(&payload(&call(None).await.unwrap())); + assert!(!paths.is_empty()); + assert!(paths.iter().all(|p| p == "sample.ts"), "{paths:?}"); + + // `docs` on the same repo → a separate docs-only index. + let paths = result_paths(&payload(&call(Some(ContentSelection::Docs)).await.unwrap())); + assert!(!paths.is_empty()); + assert!(paths.iter().all(|p| p == "README.md"), "{paths:?}"); + + // `all` → both files are searchable in one index. + let paths = result_paths(&payload(&call(Some(ContentSelection::All)).await.unwrap())); + assert!(paths.iter().any(|p| p == "sample.ts"), "{paths:?}"); + assert!(paths.iter().any(|p| p == "README.md"), "{paths:?}"); + + // Three distinct content selections → three cached entries. + assert_eq!(server.cache.lock().await.size(), 3); + } + #[tokio::test] async fn find_related_tool_call_reports_missing_chunk() { let dir = sample_source(); @@ -279,6 +440,7 @@ mod tests { repo: None, top_k: Some(5), max_snippet_lines: None, + content: None, })) .await .unwrap(); diff --git a/crates/csp/src/mcp.rs b/crates/csp/src/mcp.rs index 8e4a262..40865cb 100644 --- a/crates/csp/src/mcp.rs +++ b/crates/csp/src/mcp.rs @@ -9,6 +9,7 @@ //! testable. [`IndexCache`] holds `Arc` so it can be shared across the //! async server's tokio tasks. +use std::collections::BTreeSet; use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -27,9 +28,55 @@ use crate::utils::{format_results, is_git_url, resolve_chunk}; pub const SERVER_INSTRUCTIONS: &str = concat!( "Instant code search for any local or remote git repository. ", "Call `search` to find relevant code; call `find_related` on a result to discover similar code elsewhere. ", + "Pass `content` (`code`, `docs`, `config`, or `all`) to choose what a single call searches; ", + "it defaults to the server's configured content. ", "Prefer these tools over Grep, Glob, or Read for any question about how code works." ); +/// Every content type in canonical (enum) order — the expansion of `all` and +/// the ordering used to normalize cache keys. +const ALL_CONTENT: [ContentType; 3] = [ContentType::Code, ContentType::Docs, ContentType::Config]; + +/// Per-call content selection accepted by the MCP tools (`code | docs | config +/// | all`). Mirrors upstream semble's `ContentSelection` literal (#247). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum ContentSelection { + Code, + Docs, + Config, + All, +} + +/// Canonical form of a content list: every [`ContentType`] present, once, in +/// enum (`Ord`) order — so `[Docs, Code, Docs]` and `[Code, Docs]` name the +/// same index. +pub fn normalize_content(content: &[ContentType]) -> Vec { + content + .iter() + .copied() + .collect::>() + .into_iter() + .collect() +} + +/// Resolve a per-call `content` selection to exact index content types: +/// `None` → the server's configured default, `All` → every type, otherwise the +/// single named type (mirrors upstream `_resolve_content_selection`). +pub fn resolve_content_selection( + selection: Option, + default_content: &[ContentType], +) -> Vec { + match selection { + None => normalize_content(default_content), + Some(ContentSelection::All) => ALL_CONTENT.to_vec(), + Some(ContentSelection::Code) => vec![ContentType::Code], + Some(ContentSelection::Docs) => vec![ContentType::Docs], + Some(ContentSelection::Config) => vec![ContentType::Config], + } +} + /// Maximum number of distinct sources held in the session cache (LRU). const CACHE_MAX_SIZE: usize = 10; @@ -96,34 +143,54 @@ struct CacheEntry { revalidate_cooldown: std::time::Duration, } +/// Identity of one exact index variant in the session cache: the source (git +/// URL `@ref`, or the absolutized local path) plus its normalized content list +/// (upstream `_CacheKey = tuple[str, tuple[ContentType, ...]]`, #247). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CacheKey { + source: String, + content: Vec, +} + /// Session cache of indexed repos/paths, keyed by source (git URL `@ref`, or the -/// absolutized local path). LRU-bounded to [`CACHE_MAX_SIZE`]. Local-path entries -/// are revalidated against their live source fingerprint on query (subject to a -/// build-time-scaled cooldown), so an entry is rebuilt once its files change. +/// absolutized local path) **and** content selection, so one repo searched as +/// `code` and as `docs` holds two independent entries. LRU-bounded to +/// [`CACHE_MAX_SIZE`]. Local-path entries are revalidated against their live +/// source fingerprint on query (subject to a build-time-scaled cooldown), so an +/// entry is rebuilt once its files change. pub struct IndexCache { - tasks: IndexMap, - content: Vec, + tasks: IndexMap, seam: S, } impl IndexCache { /// A cache backed by the real on-disk `load_or_build_index`. - pub fn new(content: Vec) -> Self { - Self::with_seam(content, DiskLoadOrBuild) + pub fn new() -> Self { + Self::with_seam(DiskLoadOrBuild) + } +} + +impl Default for IndexCache { + fn default() -> Self { + Self::new() } } impl IndexCache { - pub fn with_seam(content: Vec, seam: S) -> Self { + pub fn with_seam(seam: S) -> Self { Self { tasks: IndexMap::new(), - content, seam, } } - fn compute_key(&self, source: &str, git_ref: Option<&str>) -> String { - if is_git_url(source) { + fn compute_key( + &self, + source: &str, + git_ref: Option<&str>, + content: &[ContentType], + ) -> CacheKey { + let source = if is_git_url(source) { match git_ref { Some(r) if !r.is_empty() => format!("{source}@{r}"), _ => source.to_string(), @@ -133,22 +200,33 @@ impl IndexCache { std::path::absolute(source) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| source.to_string()) + }; + CacheKey { + source, + content: normalize_content(content), } } - /// Return an index for `source`, building and caching it on first access. - /// A build failure is not cached (the next call retries). + /// Return an index for `source` restricted to `content`, building and + /// caching it on first access. Each distinct (source, content) pair is its + /// own entry. A build failure is not cached (the next call retries). /// /// A cached local-path entry is revalidated against its live source /// fingerprint once its cooldown has elapsed; a mismatch evicts it so the /// index is rebuilt below. Git URLs are never revalidated. - pub fn get(&mut self, source: &str, git_ref: Option<&str>) -> Result, String> { - let key = self.compute_key(source, git_ref); + pub fn get( + &mut self, + source: &str, + git_ref: Option<&str>, + content: &[ContentType], + ) -> Result, String> { + let key = self.compute_key(source, git_ref, content); + let content = key.content.as_slice(); let mut entry = self.tasks.shift_remove(&key); let stale = if let Some(entry) = entry.as_mut() { if entry.fingerprint.is_some() && Instant::now() >= entry.revalidate_after { - if self.seam.fingerprint(source, &self.content) != entry.fingerprint { + if self.seam.fingerprint(source, content) != entry.fingerprint { true } else { entry.revalidate_after = Instant::now() + entry.revalidate_cooldown; @@ -178,9 +256,9 @@ impl IndexCache { } let start = Instant::now(); - let index = Arc::new(self.seam.load_or_build(source, &self.content, git_ref)?); + let index = Arc::new(self.seam.load_or_build(source, content, git_ref)?); let build_elapsed = start.elapsed(); - let fingerprint = self.seam.fingerprint(source, &self.content); + let fingerprint = self.seam.fingerprint(source, content); let revalidate_cooldown = (build_elapsed * MIN_REVALIDATE_FACTOR).max(MIN_REVALIDATE_COOLDOWN); self.tasks.insert( @@ -195,9 +273,9 @@ impl IndexCache { Ok(index) } - /// Remove the cached entry for `source`. - pub fn evict(&mut self, source: &str, git_ref: Option<&str>) { - let key = self.compute_key(source, git_ref); + /// Remove the cached entry for `source` at this exact `content` selection. + pub fn evict(&mut self, source: &str, git_ref: Option<&str>, content: &[ContentType]) { + let key = self.compute_key(source, git_ref, content); self.tasks.shift_remove(&key); } @@ -207,12 +285,14 @@ impl IndexCache { } } -/// Resolve a cached index for a repo, rejecting unsafe git transport schemes and -/// missing-source cases with descriptive errors. +/// Resolve a cached index for a repo at the given (already resolved) `content` +/// selection, rejecting unsafe git transport schemes and missing-source cases +/// with descriptive errors. pub fn get_index( repo: Option<&str>, default_source: Option<&str>, default_ref: Option<&str>, + content: &[ContentType], cache: &mut IndexCache, ) -> Result, String> { if let Some(r) = repo { @@ -235,13 +315,15 @@ pub fn get_index( }; let git_ref = if use_default { default_ref } else { None }; cache - .get(source, git_ref) + .get(source, git_ref, content) .map_err(|e| format!("Failed to index {}: {e}", json!(source))) } /// `search` tool handler. Returns a JSON string (results or `{error}`), or an /// error message string on failure (mirroring the TS handler's catch). -/// `stats_file`, when `Some`, records token-savings telemetry (tests pass `None`). +/// `content` is the per-call selection already resolved against the server +/// default (see [`resolve_content_selection`]). `stats_file`, when `Some`, +/// records token-savings telemetry (tests pass `None`). // Positional transport params mirror the MCP tool signature; a struct would just // move the plumbing without clarifying it (same call as `find_related_tool`). #[allow(clippy::too_many_arguments)] @@ -251,11 +333,12 @@ pub fn search_tool( default_ref: Option<&str>, query: &str, repo: Option<&str>, + content: &[ContentType], top_k: usize, max_snippet_lines: Option, stats_file: Option<&Path>, ) -> String { - let index = match get_index(repo, default_source, default_ref, cache) { + let index = match get_index(repo, default_source, default_ref, content, cache) { Ok(idx) => idx, Err(e) => return e, }; @@ -293,11 +376,12 @@ pub fn find_related_tool( file_path: &str, line: i64, repo: Option<&str>, + content: &[ContentType], top_k: usize, max_snippet_lines: Option, stats_file: Option<&Path>, ) -> String { - let index = match get_index(repo, default_source, default_ref, cache) { + let index = match get_index(repo, default_source, default_ref, content, cache) { Ok(idx) => idx, Err(e) => return e, }; @@ -352,6 +436,8 @@ mod tests { use crate::types::Chunk; use std::cell::RefCell; + const CODE: &[ContentType] = &[ContentType::Code]; + fn empty_index() -> CspIndex { CspIndex::new(CspIndexState { model: make_stub_model(4), @@ -434,68 +520,137 @@ mod tests { #[test] fn cache_reuses_second_call() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); - let first = cache.get("/tmp/repo", None).unwrap(); - let second = cache.get("/tmp/repo", None).unwrap(); + let mut cache = IndexCache::with_seam(Stub::new()); + let first = cache.get("/tmp/repo", None, CODE).unwrap(); + let second = cache.get("/tmp/repo", None, CODE).unwrap(); assert!(Arc::ptr_eq(&first, &second)); assert_eq!(*cache.seam.path_calls.borrow(), 1); } + #[test] + fn cache_keys_on_content() { + let mut cache = IndexCache::with_seam(Stub::new()); + let code = cache.get("/tmp/repo", None, CODE).unwrap(); + let docs = cache + .get("/tmp/repo", None, &[ContentType::Code, ContentType::Docs]) + .unwrap(); + // Same repo, different content → a distinct index, not a cache hit. + assert!(!Arc::ptr_eq(&code, &docs)); + assert_eq!(cache.size(), 2); + assert_eq!(*cache.seam.path_calls.borrow(), 2); + + // Order and duplicates don't matter: the key is normalized. + let again = cache + .get( + "/tmp/repo", + None, + &[ContentType::Docs, ContentType::Code, ContentType::Docs], + ) + .unwrap(); + assert!(Arc::ptr_eq(&docs, &again)); + assert_eq!(*cache.seam.path_calls.borrow(), 2); + + // Evicting one content variant leaves the other in place. + cache.evict("/tmp/repo", None, CODE); + assert_eq!(cache.size(), 1); + assert!(Arc::ptr_eq( + &docs, + &cache + .get("/tmp/repo", None, &[ContentType::Code, ContentType::Docs]) + .unwrap() + )); + } + + #[test] + fn resolve_content_selection_maps_default_all_and_single() { + let default = [ContentType::Docs, ContentType::Code]; + // None → the server default, normalized to enum order. + assert_eq!( + resolve_content_selection(None, &default), + vec![ContentType::Code, ContentType::Docs] + ); + assert_eq!( + resolve_content_selection(Some(ContentSelection::All), &default), + vec![ContentType::Code, ContentType::Docs, ContentType::Config] + ); + assert_eq!( + resolve_content_selection(Some(ContentSelection::Config), &default), + vec![ContentType::Config] + ); + } + + #[test] + fn content_selection_deserializes_lowercase_only() { + for (raw, want) in [ + ("code", ContentSelection::Code), + ("docs", ContentSelection::Docs), + ("config", ContentSelection::Config), + ("all", ContentSelection::All), + ] { + let got: ContentSelection = serde_json::from_value(json!(raw)).unwrap(); + assert_eq!(got, want); + } + assert!(serde_json::from_value::(json!("Docs")).is_err()); + assert!(serde_json::from_value::(json!("tests")).is_err()); + } + #[test] fn cache_evict_forces_rebuild() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); - cache.get("/tmp/repo", None).unwrap(); + let mut cache = IndexCache::with_seam(Stub::new()); + cache.get("/tmp/repo", None, CODE).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 1); - cache.evict("/tmp/repo", None); + cache.evict("/tmp/repo", None, CODE); assert_eq!(cache.size(), 0); - cache.get("/tmp/repo", None).unwrap(); + cache.get("/tmp/repo", None, CODE).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 2); } #[test] fn cache_lru_evicts_oldest() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); + let mut cache = IndexCache::with_seam(Stub::new()); for i in 0..10 { - cache.get(&format!("/tmp/repo-{i}"), None).unwrap(); + cache.get(&format!("/tmp/repo-{i}"), None, CODE).unwrap(); } assert_eq!(cache.size(), 10); - cache.get("/tmp/repo-10", None).unwrap(); + cache.get("/tmp/repo-10", None, CODE).unwrap(); assert_eq!(cache.size(), 10); // repo-0 (oldest) was evicted → re-getting it rebuilds. let before = *cache.seam.path_calls.borrow(); - cache.get("/tmp/repo-0", None).unwrap(); + cache.get("/tmp/repo-0", None, CODE).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), before + 1); } #[test] fn cache_git_vs_path_routing() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); - cache.get("https://github.com/org/repo.git", None).unwrap(); + let mut cache = IndexCache::with_seam(Stub::new()); + cache + .get("https://github.com/org/repo.git", None, CODE) + .unwrap(); assert_eq!(*cache.seam.git_calls.borrow(), 1); assert_eq!(*cache.seam.path_calls.borrow(), 0); - cache.get("/tmp/local", None).unwrap(); + cache.get("/tmp/local", None, CODE).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 1); } #[test] fn cache_revalidates_stale_local_path() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); - let key = cache.compute_key("/tmp/repo", None); + let mut cache = IndexCache::with_seam(Stub::new()); + let key = cache.compute_key("/tmp/repo", None, CODE); - cache.get("/tmp/repo", None).unwrap(); + cache.get("/tmp/repo", None, CODE).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 1); assert!(cache.tasks.get(&key).unwrap().revalidate_cooldown >= MIN_REVALIDATE_COOLDOWN); // Within the cooldown window the entry is served without a fingerprint // check, so it's not rebuilt even if the fingerprint has drifted. *cache.seam.fingerprint.borrow_mut() = Some("fp2".to_string()); - cache.get("/tmp/repo", None).unwrap(); + cache.get("/tmp/repo", None, CODE).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 1); // Force the cooldown to have elapsed → the next get revalidates, sees the // changed fingerprint, evicts, and rebuilds. cache.tasks.get_mut(&key).unwrap().revalidate_after = Instant::now(); - cache.get("/tmp/repo", None).unwrap(); + cache.get("/tmp/repo", None, CODE).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 2); assert_eq!(cache.size(), 1); @@ -503,22 +658,22 @@ mod tests { // fingerprint → revalidated but matches → served, no rebuild, and the // next revalidation is deferred by a fresh cooldown window. cache.tasks.get_mut(&key).unwrap().revalidate_after = Instant::now(); - cache.get("/tmp/repo", None).unwrap(); + cache.get("/tmp/repo", None, CODE).unwrap(); assert_eq!(*cache.seam.path_calls.borrow(), 2); assert!(cache.tasks.get(&key).unwrap().revalidate_after > Instant::now()); } #[test] fn cache_git_url_not_revalidated() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); + let mut cache = IndexCache::with_seam(Stub::new()); let url = "https://github.com/org/repo.git"; - cache.get(url, None).unwrap(); + cache.get(url, None, CODE).unwrap(); assert_eq!(*cache.seam.git_calls.borrow(), 1); // Even if the (local-only) fingerprint changes, git URLs are keyed by // URL+ref and never revalidated → always served from cache. *cache.seam.fingerprint.borrow_mut() = Some("fp2".to_string()); - cache.get(url, None).unwrap(); + cache.get(url, None, CODE).unwrap(); assert_eq!(*cache.seam.git_calls.borrow(), 1); } @@ -526,47 +681,55 @@ mod tests { fn cache_failure_not_poisoned() { let mut seam = Stub::new(); seam.fail = true; - let mut cache = IndexCache::with_seam(vec![ContentType::Code], seam); - assert!(cache.get("/tmp/will-fail", None).is_err()); + let mut cache = IndexCache::with_seam(seam); + assert!(cache.get("/tmp/will-fail", None, CODE).is_err()); assert_eq!(cache.size(), 0); } #[test] fn get_index_rejects_unsafe_schemes() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); + let mut cache = IndexCache::with_seam(Stub::new()); for url in [ "ssh://git@github.com/o/r.git", "git://github.com/o/r.git", "file:///tmp/x", ] { - let err = get_index(Some(url), None, None, &mut cache).unwrap_err(); + let err = get_index(Some(url), None, None, CODE, &mut cache).unwrap_err(); assert!(err.contains("Only https://, http://"), "{url}: {err}"); } } #[test] fn get_index_requires_source() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); - let err = get_index(None, None, None, &mut cache).unwrap_err(); + let mut cache = IndexCache::with_seam(Stub::new()); + let err = get_index(None, None, None, CODE, &mut cache).unwrap_err(); assert!(err.contains("No repo specified")); } #[test] fn get_index_allows_https_and_path() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); - assert!(get_index(Some("https://github.com/o/r.git"), None, None, &mut cache).is_ok()); - assert!(get_index(None, Some("/tmp/default"), None, &mut cache).is_ok()); + let mut cache = IndexCache::with_seam(Stub::new()); + assert!(get_index( + Some("https://github.com/o/r.git"), + None, + None, + CODE, + &mut cache + ) + .is_ok()); + assert!(get_index(None, Some("/tmp/default"), None, CODE, &mut cache).is_ok()); } #[test] fn search_tool_no_results() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); + let mut cache = IndexCache::with_seam(Stub::new()); let out = search_tool( &mut cache, Some("/tmp/repo"), None, "anything", None, + CODE, 5, None, None, @@ -592,13 +755,14 @@ mod tests { #[test] fn search_tool_returns_results_json() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); + let mut cache = IndexCache::with_seam(OneChunkSeam); let out = search_tool( &mut cache, Some("/tmp/repo"), None, "main", None, + CODE, 5, None, None, @@ -612,7 +776,7 @@ mod tests { #[test] fn search_tool_records_savings_when_stats_file_given() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); + let mut cache = IndexCache::with_seam(OneChunkSeam); let dir = tempfile::tempdir().unwrap(); let stats_file = dir.path().join("savings.jsonl"); let _ = search_tool( @@ -621,6 +785,7 @@ mod tests { None, "main", None, + CODE, 5, None, Some(&stats_file), @@ -633,13 +798,14 @@ mod tests { #[test] fn search_tool_respects_max_snippet_lines_zero() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); + let mut cache = IndexCache::with_seam(OneChunkSeam); let out = search_tool( &mut cache, Some("/tmp/repo"), None, "main", None, + CODE, 5, Some(0), None, @@ -653,7 +819,7 @@ mod tests { #[test] fn find_related_no_chunk_message() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); + let mut cache = IndexCache::with_seam(OneChunkSeam); let out = find_related_tool( &mut cache, Some("/tmp/repo"), @@ -661,6 +827,7 @@ mod tests { "nope.ts", 1, None, + CODE, 5, None, None, @@ -670,7 +837,7 @@ mod tests { #[test] fn find_related_returns_json_for_known_chunk() { - let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); + let mut cache = IndexCache::with_seam(OneChunkSeam); let out = find_related_tool( &mut cache, Some("/tmp/repo"), @@ -678,6 +845,7 @@ mod tests { "a.ts", 5, None, + CODE, 5, None, None, diff --git a/crates/csp/src/types.rs b/crates/csp/src/types.rs index 7cce7ea..d65849a 100644 --- a/crates/csp/src/types.rs +++ b/crates/csp/src/types.rs @@ -18,7 +18,9 @@ pub enum CallType { } /// Content type for indexing and search pipeline selection. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// Variant order is canonical (`code < docs < config`): `Ord` drives the +/// normalized content list used as the MCP session-cache key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ContentType { Code, diff --git a/plugins/csp/README.md b/plugins/csp/README.md index af70f3f..39b06b1 100644 --- a/plugins/csp/README.md +++ b/plugins/csp/README.md @@ -31,7 +31,7 @@ codex plugin add csp@pleaseai ## Customization -By default the MCP server indexes only code files. To also index documentation or config, append `--content` to the server args, e.g. `["@pleaseai/csp", "mcp", "--content", "all"]`. +By default the MCP server indexes only code files. To also index documentation or config, append `--content` to the server args, e.g. `["@pleaseai/csp", "mcp", "--content", "all"]`. A single tool call can also override this with its `content` argument (`code`, `docs`, `config`, or `all`). ## Layout