1use super::dynamic_tools::{categorize_tool, ToolCategory};
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 if 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
135 let level = crate::core::config::CompressionLevel::effective(&cfg);
136 let mode = crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
137 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
138 return tools;
139 }
140 tools
141 .into_iter()
142 .map(|mut t| {
143 let compressed = crate::core::terse::mcp_compress::compress_description(
144 t.name.as_ref(),
145 t.description.as_deref().unwrap_or(""),
146 mode,
147 );
148 t.description = Some(compressed.into());
149 t
150 })
151 .collect()
152}
153
154#[must_use]
164pub fn category_gate_applies(supports_list_changed: bool, explicit_profile: bool) -> bool {
165 supports_list_changed && !explicit_profile
166}
167
168#[must_use]
174pub fn needs_invoker(
175 full_mode: bool,
176 already_present: bool,
177 invoker_role_allowed: bool,
178 disabled: &[String],
179) -> bool {
180 !full_mode && !already_present && invoker_role_allowed && !disabled.iter().any(|d| d == INVOKER)
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn internal_tools_never_visible_even_in_power() {
189 let p = ToolProfile::Power;
191 assert!(!is_tool_visible("ctx_metrics", &p, &[], false, true));
192 assert!(!is_tool_visible("ctx_cost", &p, &[], false, true));
193 assert!(!is_tool_visible("ctx_discover_tools", &p, &[], false, true));
194 }
195
196 #[test]
197 fn core_tool_visible_under_power() {
198 assert!(is_tool_visible(
199 "ctx_read",
200 &ToolProfile::Power,
201 &[],
202 false,
203 true
204 ));
205 }
206
207 #[test]
208 fn standard_exposes_its_advertised_tools() {
209 let p = ToolProfile::Standard;
213 assert!(is_tool_visible("ctx_architecture", &p, &[], false, true));
214 assert!(is_tool_visible("ctx_semantic_search", &p, &[], false, true));
215 assert!(is_tool_visible("ctx_callgraph", &p, &[], false, true));
216 }
217
218 #[test]
219 fn minimal_hides_non_minimal_tools() {
220 let p = ToolProfile::Minimal;
221 assert!(is_tool_visible("ctx_read", &p, &[], false, true));
222 assert!(!is_tool_visible("ctx_architecture", &p, &[], false, true));
223 }
224
225 #[test]
226 fn disabled_list_filters() {
227 let disabled = vec!["ctx_read".to_string()];
228 assert!(!is_tool_visible(
229 "ctx_read",
230 &ToolProfile::Power,
231 &disabled,
232 false,
233 true
234 ));
235 }
236
237 #[test]
238 fn zed_hides_ctx_edit_only() {
239 let p = ToolProfile::Power;
240 assert!(!is_tool_visible("ctx_edit", &p, &[], true, true));
241 assert!(is_tool_visible("ctx_read", &p, &[], true, true));
242 }
243
244 #[test]
245 fn role_block_hides_tool() {
246 assert!(!is_tool_visible(
247 "ctx_read",
248 &ToolProfile::Power,
249 &[],
250 false,
251 false
252 ));
253 }
254
255 #[test]
256 fn category_gate_only_in_default_lean_mode() {
257 assert!(category_gate_applies(true, false));
260 assert!(!category_gate_applies(true, true));
262 assert!(!category_gate_applies(false, false));
264 assert!(!category_gate_applies(false, true));
265 }
266
267 #[test]
268 fn invoker_added_when_missing_in_lazy_mode() {
269 assert!(needs_invoker(false, false, true, &[]));
270 }
271
272 #[test]
273 fn invoker_not_added_in_full_mode() {
274 assert!(!needs_invoker(true, false, true, &[]));
275 }
276
277 #[test]
278 fn invoker_not_duplicated_when_present() {
279 assert!(!needs_invoker(false, true, true, &[]));
280 }
281
282 #[test]
283 fn invoker_respects_role_and_disabled() {
284 assert!(!needs_invoker(false, false, false, &[]));
285 assert!(!needs_invoker(
286 false,
287 false,
288 true,
289 &["ctx_call".to_string()]
290 ));
291 }
292
293 #[test]
303 fn core_tool_surface_stays_within_budget() {
304 const PER_TOOL_BUDGET: usize = 300;
305 const TOTAL_BUDGET: usize = 2220;
306
307 let _guard = crate::core::data_dir::isolated_data_dir();
308 let core = crate::tool_defs::core_tool_names();
309 let defs: Vec<_> = crate::server::registry::build_registry()
310 .tool_defs()
311 .into_iter()
312 .filter(|t| core.contains(&t.name.as_ref()))
313 .collect();
314 assert_eq!(defs.len(), core.len(), "every core tool must be registered");
315
316 let mut total = 0usize;
317 for t in &defs {
318 let desc = t.description.as_deref().unwrap_or("");
319 let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
320 let cost = crate::core::tokens::count_tokens(desc)
321 + crate::core::tokens::count_tokens(&schema);
322 eprintln!("{:24} {cost:4} tok", t.name.as_ref());
323 assert!(
324 cost <= PER_TOOL_BUDGET,
325 "{} costs {cost} tok (budget {PER_TOOL_BUDGET}) — trim its description/schema",
326 t.name
327 );
328 total += cost;
329 }
330 eprintln!("CORE TOTAL: {total} tok / {} tools", defs.len());
331 assert!(
332 total <= TOTAL_BUDGET,
333 "core surface costs {total} tok (budget {TOTAL_BUDGET})"
334 );
335 }
336}