1use super::dynamic_tools::{ToolCategory, categorize_tool};
14use crate::core::tool_profiles::ToolProfile;
15
16pub const INVOKER: &str = "ctx_call";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CandidateSet {
23 Full,
25 Unified,
27 ProfileAuthoritative,
30 LazyCore,
33}
34
35#[must_use]
39pub fn candidate_set(full_mode: bool, unified_env: bool, explicit_profile: bool) -> CandidateSet {
40 if full_mode {
41 CandidateSet::Full
42 } else if unified_env {
43 CandidateSet::Unified
44 } else if explicit_profile {
45 CandidateSet::ProfileAuthoritative
46 } else {
47 CandidateSet::LazyCore
48 }
49}
50
51#[must_use]
54pub fn explicit_profile(cfg: &crate::core::config::Config) -> bool {
55 cfg.tool_profile.is_some()
56 || !cfg.tools_enabled.is_empty()
57 || std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
58}
59
60#[must_use]
66pub fn is_tool_visible(
67 name: &str,
68 profile: &ToolProfile,
69 disabled: &[String],
70 is_zed: bool,
71 role_allows: bool,
72) -> bool {
73 if categorize_tool(name) == ToolCategory::Internal {
74 return false;
75 }
76 if !profile.is_tool_enabled(name) {
77 return false;
78 }
79 if disabled.iter().any(|d| d == name) {
80 return false;
81 }
82 if is_zed && name == "ctx_edit" {
83 return false;
84 }
85 role_allows
86}
87
88#[must_use]
94pub fn advertised_tool_defs_default() -> Vec<rmcp::model::Tool> {
95 let cfg = crate::core::config::Config::load();
96 let disabled = cfg.disabled_tools_effective();
97 let profile = cfg.tool_profile_effective();
98 let full_mode = crate::tool_defs::is_full_mode();
99 let registry = crate::server::registry::build_registry();
100
101 let candidate = candidate_set(
102 full_mode,
103 std::env::var("LEAN_CTX_UNIFIED").is_ok(),
104 explicit_profile(&cfg),
105 );
106 let pool: Vec<rmcp::model::Tool> = match candidate {
107 CandidateSet::Full | CandidateSet::ProfileAuthoritative => registry.tool_defs(),
108 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
109 CandidateSet::LazyCore => {
110 let core = crate::tool_defs::core_tool_names();
111 registry
112 .tool_defs()
113 .into_iter()
114 .filter(|t| core.contains(&t.name.as_ref()))
115 .collect()
116 }
117 };
118
119 let mut tools: Vec<_> = pool
120 .into_iter()
121 .filter(|t| is_tool_visible(t.name.as_ref(), &profile, &disabled, false, true))
122 .collect();
123
124 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
125 if needs_invoker(full_mode, already, true, &disabled)
126 && let Some(def) = registry
127 .tool_defs()
128 .into_iter()
129 .find(|t| t.name.as_ref() == INVOKER)
130 {
131 tools.push(def);
132 }
133
134 let level = crate::core::config::CompressionLevel::effective(&cfg);
135 let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
136 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
137 return tools;
138 }
139 tools
140 .into_iter()
141 .map(|mut t| {
142 let compressed = crate::core::terse::mcp_compress::compress_description(
143 t.name.as_ref(),
144 t.description.as_deref().unwrap_or(""),
145 mode,
146 );
147 t.description = Some(compressed.into());
148 t
149 })
150 .collect()
151}
152
153#[must_use]
163pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
164 supports_list_changed && !explicit_profile
165}
166
167#[must_use]
173pub fn needs_invoker(
174 full_mode: bool,
175 already_present: bool,
176 invoker_role_allowed: bool,
177 disabled: &[String],
178) -> bool {
179 !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn internal_tools_never_visible_even_in_power() {
188 let p = ToolProfile::Power;
190 assert!(!is_tool_visible("ctx_metrics", &p, &[], false, true));
191 assert!(!is_tool_visible("ctx_cost", &p, &[], false, true));
192 assert!(!is_tool_visible("ctx_discover_tools", &p, &[], false, true));
193 }
194
195 #[test]
196 fn core_tool_visible_under_power() {
197 assert!(is_tool_visible(
198 "ctx_read",
199 &ToolProfile::Power,
200 &[],
201 false,
202 true
203 ));
204 }
205
206 #[test]
207 fn standard_exposes_its_advertised_tools() {
208 let p = ToolProfile::Standard;
212 assert!(is_tool_visible("ctx_architecture", &p, &[], false, true));
213 assert!(is_tool_visible("ctx_semantic_search", &p, &[], false, true));
214 assert!(is_tool_visible("ctx_callgraph", &p, &[], false, true));
215 }
216
217 #[test]
218 fn minimal_hides_non_minimal_tools() {
219 let p = ToolProfile::Minimal;
220 assert!(is_tool_visible("ctx_read", &p, &[], false, true));
221 assert!(!is_tool_visible("ctx_architecture", &p, &[], false, true));
222 }
223
224 #[test]
225 fn disabled_list_filters() {
226 let disabled = vec!["ctx_read".to_string()];
227 assert!(!is_tool_visible(
228 "ctx_read",
229 &ToolProfile::Power,
230 &disabled,
231 false,
232 true
233 ));
234 }
235
236 #[test]
237 fn zed_hides_ctx_edit_only() {
238 let p = ToolProfile::Power;
239 assert!(!is_tool_visible("ctx_edit", &p, &[], true, true));
240 assert!(is_tool_visible("ctx_read", &p, &[], true, true));
241 }
242
243 #[test]
244 fn role_block_hides_tool() {
245 assert!(!is_tool_visible(
246 "ctx_read",
247 &ToolProfile::Power,
248 &[],
249 false,
250 false
251 ));
252 }
253
254 #[test]
255 fn category_gate_only_in_default_lean_mode() {
256 assert!(category_gate_applies(true, false));
259 assert!(!category_gate_applies(true, true));
261 assert!(!category_gate_applies(false, false));
263 assert!(!category_gate_applies(false, true));
264 }
265
266 #[test]
267 fn invoker_added_when_missing_in_lazy_mode() {
268 assert!(needs_invoker(false, false, true, &[]));
269 }
270
271 #[test]
272 fn invoker_not_added_in_full_mode() {
273 assert!(!needs_invoker(true, false, true, &[]));
274 }
275
276 #[test]
277 fn invoker_not_duplicated_when_present() {
278 assert!(!needs_invoker(false, true, true, &[]));
279 }
280
281 #[test]
282 fn invoker_respects_role_and_disabled() {
283 assert!(!needs_invoker(false, false, false, &[]));
284 assert!(!needs_invoker(
285 false,
286 false,
287 true,
288 &["ctx_call".to_string()]
289 ));
290 }
291
292 #[test]
306 fn core_tool_surface_stays_within_budget() {
307 const PER_TOOL_BUDGET: usize = 300;
308 const TOTAL_BUDGET: usize = 2260;
309
310 let _guard = crate::core::data_dir::isolated_data_dir();
311 let core = crate::tool_defs::core_tool_names();
312 let defs: Vec<_> = crate::server::registry::build_registry()
313 .tool_defs()
314 .into_iter()
315 .filter(|t| core.contains(&t.name.as_ref()))
316 .collect();
317 assert_eq!(defs.len(), core.len(), "every core tool must be registered");
318
319 let mut total = 0usize;
320 for t in &defs {
321 let desc = t.description.as_deref().unwrap_or("");
322 let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
323 let cost = crate::core::tokens::count_tokens(desc)
324 + crate::core::tokens::count_tokens(&schema);
325 eprintln!("{:24} {cost:4} tok", t.name.as_ref());
326 assert!(
327 cost <= PER_TOOL_BUDGET,
328 "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
329 t.name
330 );
331 total += cost;
332 }
333 eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
334 assert!(
335 total <= TOTAL_BUDGET,
336 "core surface costs {total} tok (budget {TOTAL_BUDGET})"
337 );
338 }
339}