1use std::path::Path;
19
20use serde::Serialize;
21
22use crate::core::a2a::cost_attribution::CostStore;
23use crate::core::context_overhead::tool_tokens;
24use crate::core::rules_overhead::{RulesFileCost, collect_rules_files, duplicate_clients};
25
26const LOW_USE_TOKEN_FLOOR: usize = 150;
29const LOW_USE_CALL_SHARE: f64 = 0.01;
31const STALE_FACT_DAYS: i64 = 30;
33
34#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum ToolStatus {
37 Active,
39 LowUse,
41 Unused,
43 Unknown,
45}
46
47impl ToolStatus {
48 #[must_use]
49 pub fn label(self) -> &'static str {
50 match self {
51 ToolStatus::Active => "active",
52 ToolStatus::LowUse => "low-use",
53 ToolStatus::Unused => "unused",
54 ToolStatus::Unknown => "unknown",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct ToolEntry {
61 pub name: String,
62 pub schema_tokens: usize,
63 pub calls: u64,
64 pub last_used: Option<String>,
65 pub status: ToolStatus,
66 pub action: String,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct RuleEntry {
71 pub path: String,
72 pub file_tokens: usize,
73 pub lean_ctx_tokens: usize,
74 pub carries_full: bool,
75 pub clients: Vec<String>,
76 pub action: String,
77}
78
79#[derive(Debug, Clone, Serialize, Default)]
80pub struct KnowledgeHealth {
81 pub total_facts: usize,
82 pub active_facts: usize,
83 pub stale_facts: usize,
84 pub action: String,
85}
86
87#[derive(Debug, Clone, Serialize)]
88pub struct ToolHealthReport {
89 pub tool_profile: String,
90 pub advertised_tools: usize,
91 pub tool_schema_tokens: usize,
92 pub instruction_tokens: usize,
93 pub rules_tokens: usize,
94 pub fixed_total_tokens: usize,
96 pub has_usage_data: bool,
97 pub total_recorded_calls: u64,
98 pub unused_tools: usize,
99 pub unused_tool_tokens: usize,
101 pub tools: Vec<ToolEntry>,
102 pub rules: Vec<RuleEntry>,
103 pub duplicate_clients: Vec<(String, usize)>,
104 pub knowledge: KnowledgeHealth,
105}
106
107fn classify(has_usage: bool, calls: u64, schema_tokens: usize, total_calls: u64) -> ToolStatus {
108 if !has_usage {
109 return ToolStatus::Unknown;
110 }
111 if calls == 0 {
112 return ToolStatus::Unused;
113 }
114 if schema_tokens >= LOW_USE_TOKEN_FLOOR
115 && (calls as f64) < (total_calls as f64) * LOW_USE_CALL_SHARE
116 {
117 return ToolStatus::LowUse;
118 }
119 ToolStatus::Active
120}
121
122fn action_for(status: ToolStatus, calls: u64, schema_tokens: usize) -> String {
123 match status {
124 ToolStatus::Unused => format!(
125 "never called — trim via a leaner tool profile to reclaim {schema_tokens} tok/session"
126 ),
127 ToolStatus::LowUse => {
128 format!("rarely used ({calls}×) yet costs {schema_tokens} tok/session — review")
129 }
130 ToolStatus::Active | ToolStatus::Unknown => String::new(),
131 }
132}
133
134#[must_use]
137pub fn build_report(
138 advertised: &[rmcp::model::Tool],
139 usage: &CostStore,
140 rules: &[RulesFileCost],
141 duplicates: Vec<(String, usize)>,
142 instruction_tokens: usize,
143 tool_profile: String,
144 knowledge: KnowledgeHealth,
145) -> ToolHealthReport {
146 let total_recorded_calls: u64 = usage.tools.values().map(|t| t.total_calls).sum();
147 let has_usage_data = total_recorded_calls > 0;
148
149 let mut tools: Vec<ToolEntry> = advertised
150 .iter()
151 .map(|t| {
152 let name = t.name.as_ref().to_string();
153 let schema_tokens = tool_tokens(t);
154 let (calls, last_used) = usage
155 .tools
156 .get(&name)
157 .map_or((0, None), |c| (c.total_calls, c.last_used.clone()));
158 let status = classify(has_usage_data, calls, schema_tokens, total_recorded_calls);
159 let action = action_for(status, calls, schema_tokens);
160 ToolEntry {
161 name,
162 schema_tokens,
163 calls,
164 last_used,
165 status,
166 action,
167 }
168 })
169 .collect();
170 tools.sort_by(|a, b| a.name.cmp(&b.name));
171
172 let tool_schema_tokens: usize = tools.iter().map(|t| t.schema_tokens).sum();
173 let unused_tools = tools
174 .iter()
175 .filter(|t| t.status == ToolStatus::Unused)
176 .count();
177 let unused_tool_tokens = tools
178 .iter()
179 .filter(|t| t.status == ToolStatus::Unused)
180 .map(|t| t.schema_tokens)
181 .sum();
182
183 let dup_clients: std::collections::HashSet<&str> =
184 duplicates.iter().map(|(c, _)| c.as_str()).collect();
185 let rules_out: Vec<RuleEntry> = rules
186 .iter()
187 .map(|r| {
188 let is_dup = r.carries_full && r.clients.iter().any(|c| dup_clients.contains(c));
189 let action = if is_dup {
190 "duplicate full lean-ctx source — run `lean-ctx rules dedup --apply`".to_string()
191 } else {
192 String::new()
193 };
194 RuleEntry {
195 path: r.path.clone(),
196 file_tokens: r.file_tokens,
197 lean_ctx_tokens: r.lean_ctx_tokens,
198 carries_full: r.carries_full,
199 clients: r.clients.iter().map(|c| (*c).to_string()).collect(),
200 action,
201 }
202 })
203 .collect();
204
205 let rules_tokens: usize = rules_out.iter().map(|r| r.file_tokens).sum();
206 let fixed_total_tokens = tool_schema_tokens + instruction_tokens + rules_tokens;
207
208 ToolHealthReport {
209 tool_profile,
210 advertised_tools: tools.len(),
211 tool_schema_tokens,
212 instruction_tokens,
213 rules_tokens,
214 fixed_total_tokens,
215 has_usage_data,
216 total_recorded_calls,
217 unused_tools,
218 unused_tool_tokens,
219 tools,
220 rules: rules_out,
221 duplicate_clients: duplicates,
222 knowledge,
223 }
224}
225
226fn resolve_tool_profile() -> String {
227 let cfg = crate::core::config::Config::load();
228 if crate::server::tool_visibility::explicit_profile(&cfg) {
229 cfg.tool_profile_effective().as_str().to_string()
230 } else {
231 "lean (default)".to_string()
232 }
233}
234
235fn knowledge_health(project: &Path) -> KnowledgeHealth {
236 let Some(knowledge) =
237 crate::core::knowledge::ProjectKnowledge::load(&project.to_string_lossy())
238 else {
239 return KnowledgeHealth::default();
240 };
241 let total = knowledge.facts.len();
242 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
243 let now = chrono::Utc::now();
244 let stale = current
245 .iter()
246 .filter(|f| (now - f.created_at).num_days() > STALE_FACT_DAYS && f.retrieval_count == 0)
247 .count();
248 let action = if stale > 0 {
249 format!(
250 "{stale} stale fact(s) (>{STALE_FACT_DAYS}d, never retrieved) — review with `lean-ctx knowledge`"
251 )
252 } else {
253 String::new()
254 };
255 KnowledgeHealth {
256 total_facts: total,
257 active_facts: current.len(),
258 stale_facts: stale,
259 action,
260 }
261}
262
263#[must_use]
265pub fn compute(home: &Path, project: &Path) -> ToolHealthReport {
266 let advertised = crate::server::tool_visibility::advertised_tool_defs_default();
267 let usage = CostStore::load();
268 let rules = collect_rules_files(home, project);
269 let duplicates = duplicate_clients(&rules);
270 let instructions = crate::instructions::build_instructions(crate::tools::CrpMode::effective());
271 let instruction_tokens = crate::core::tokens::count_tokens(&instructions);
272 let knowledge = knowledge_health(project);
273 build_report(
274 &advertised,
275 &usage,
276 &rules,
277 duplicates,
278 instruction_tokens,
279 resolve_tool_profile(),
280 knowledge,
281 )
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::core::a2a::cost_attribution::ToolCost;
288
289 fn tool(name: &'static str) -> rmcp::model::Tool {
290 crate::tool_defs::tool_def(
291 name,
292 "a representative description used to give the schema some token weight",
293 serde_json::json!({
294 "type": "object",
295 "properties": { "path": { "type": "string", "description": "file path" } }
296 }),
297 )
298 }
299
300 fn usage_with(calls: &[(&str, u64)]) -> CostStore {
301 let mut store = CostStore::default();
302 for (name, n) in calls {
303 store.tools.insert(
304 (*name).to_string(),
305 ToolCost {
306 tool_name: (*name).to_string(),
307 total_calls: *n,
308 last_used: Some("2026-06-01T00:00:00+00:00".to_string()),
309 ..Default::default()
310 },
311 );
312 }
313 store
314 }
315
316 #[test]
317 fn classify_unknown_without_usage_history() {
318 assert_eq!(classify(false, 0, 500, 0), ToolStatus::Unknown);
319 assert_eq!(classify(false, 9, 500, 0), ToolStatus::Unknown);
320 }
321
322 #[test]
323 fn classify_unused_when_history_exists_but_tool_never_called() {
324 assert_eq!(classify(true, 0, 500, 1000), ToolStatus::Unused);
325 }
326
327 #[test]
328 fn classify_low_use_for_expensive_rarely_called_tool() {
329 assert_eq!(classify(true, 1, 400, 10_000), ToolStatus::LowUse);
331 assert_eq!(classify(true, 1, 50, 10_000), ToolStatus::Active);
333 }
334
335 #[test]
336 fn classify_active_for_well_used_tool() {
337 assert_eq!(classify(true, 500, 400, 1000), ToolStatus::Active);
338 }
339
340 #[test]
341 fn build_report_flags_unused_and_sorts_tools() {
342 let advertised = vec![tool("ctx_search"), tool("ctx_read"), tool("ctx_shell")];
343 let usage = usage_with(&[("ctx_read", 40)]);
345 let report = build_report(
346 &advertised,
347 &usage,
348 &[],
349 Vec::new(),
350 100,
351 "lean (default)".to_string(),
352 KnowledgeHealth::default(),
353 );
354
355 assert!(report.has_usage_data);
356 assert_eq!(report.total_recorded_calls, 40);
357 let names: Vec<&str> = report.tools.iter().map(|t| t.name.as_str()).collect();
359 assert_eq!(names, vec!["ctx_read", "ctx_search", "ctx_shell"]);
360 assert_eq!(report.unused_tools, 2);
362 assert!(report.unused_tool_tokens > 0);
363 let read = report.tools.iter().find(|t| t.name == "ctx_read").unwrap();
364 assert_eq!(read.status, ToolStatus::Active);
365 assert!(read.last_used.is_some());
366 assert_eq!(
368 report.fixed_total_tokens,
369 report.tool_schema_tokens + 100 + report.rules_tokens
370 );
371 }
372
373 #[test]
374 fn build_report_unknown_status_without_history() {
375 let advertised = vec![tool("ctx_read")];
376 let report = build_report(
377 &advertised,
378 &CostStore::default(),
379 &[],
380 Vec::new(),
381 0,
382 "lean (default)".to_string(),
383 KnowledgeHealth::default(),
384 );
385 assert!(!report.has_usage_data);
386 assert_eq!(report.unused_tools, 0, "never flag rot without history");
387 assert_eq!(report.tools[0].status, ToolStatus::Unknown);
388 }
389
390 #[test]
391 fn build_report_marks_duplicate_rules() {
392 let rules = vec![
393 RulesFileCost {
394 path: "a/.cursor/rules/lean-ctx.mdc".into(),
395 file_tokens: 200,
396 lean_ctx_tokens: 200,
397 carries_full: true,
398 clients: vec!["cursor"],
399 },
400 RulesFileCost {
401 path: "a/.cursorrules".into(),
402 file_tokens: 150,
403 lean_ctx_tokens: 150,
404 carries_full: true,
405 clients: vec!["cursor"],
406 },
407 ];
408 let dups = duplicate_clients(&rules);
409 let report = build_report(
410 &[],
411 &CostStore::default(),
412 &rules,
413 dups,
414 0,
415 "lean (default)".to_string(),
416 KnowledgeHealth::default(),
417 );
418 assert_eq!(report.rules.len(), 2);
419 assert!(
420 report.rules.iter().all(|r| r.action.contains("dedup")),
421 "both cursor full sources flagged as duplicates"
422 );
423 assert_eq!(report.rules_tokens, 350);
424 }
425
426 #[test]
427 fn compute_smoke_runs_and_counts_advertised_tools() {
428 let tmp = tempfile::tempdir().unwrap();
429 let report = compute(tmp.path(), tmp.path());
430 let expected = crate::server::tool_visibility::advertised_tool_defs_default().len();
431 assert_eq!(report.advertised_tools, expected);
432 assert!(report.tool_schema_tokens > 0);
433 assert!(report.fixed_total_tokens >= report.tool_schema_tokens);
434 }
435}