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 pub value_per_1k_tokens: f64,
70}
71
72#[derive(Debug, Clone, Serialize)]
73pub struct RuleEntry {
74 pub path: String,
75 pub file_tokens: usize,
76 pub lean_ctx_tokens: usize,
77 pub carries_full: bool,
78 pub clients: Vec<String>,
79 pub action: String,
80}
81
82#[derive(Debug, Clone, Serialize, Default)]
83pub struct KnowledgeHealth {
84 pub total_facts: usize,
85 pub active_facts: usize,
86 pub stale_facts: usize,
87 pub action: String,
88}
89
90#[derive(Debug, Clone, Serialize)]
91pub struct ToolHealthReport {
92 pub tool_profile: String,
93 pub advertised_tools: usize,
94 pub tool_schema_tokens: usize,
95 pub instruction_tokens: usize,
96 pub rules_tokens: usize,
97 pub fixed_total_tokens: usize,
99 pub has_usage_data: bool,
100 pub total_recorded_calls: u64,
101 pub unused_tools: usize,
102 pub unused_tool_tokens: usize,
104 pub disable_candidates: Vec<String>,
106 pub reclaimable_tokens: usize,
108 pub disable_action: String,
110 pub footprint_note: Option<String>,
113 pub tools: Vec<ToolEntry>,
114 pub rules: Vec<RuleEntry>,
115 pub duplicate_clients: Vec<(String, usize)>,
116 pub knowledge: KnowledgeHealth,
117}
118
119fn classify(has_usage: bool, calls: u64, schema_tokens: usize, total_calls: u64) -> ToolStatus {
120 if !has_usage {
121 return ToolStatus::Unknown;
122 }
123 if calls == 0 {
124 return ToolStatus::Unused;
125 }
126 if schema_tokens >= LOW_USE_TOKEN_FLOOR
127 && (calls as f64) < (total_calls as f64) * LOW_USE_CALL_SHARE
128 {
129 return ToolStatus::LowUse;
130 }
131 ToolStatus::Active
132}
133
134fn value_per_1k(calls: u64, schema_tokens: usize) -> f64 {
136 if schema_tokens == 0 {
137 0.0
138 } else {
139 calls as f64 / schema_tokens as f64 * 1000.0
140 }
141}
142
143fn action_for(status: ToolStatus, calls: u64, schema_tokens: usize) -> String {
144 match status {
145 ToolStatus::Unused => format!(
146 "never called — trim via a leaner tool profile to reclaim {schema_tokens} tok/session"
147 ),
148 ToolStatus::LowUse => {
149 format!("rarely used ({calls}×) yet costs {schema_tokens} tok/session — review")
150 }
151 ToolStatus::Active | ToolStatus::Unknown => String::new(),
152 }
153}
154
155fn resolve_alias(name: &str) -> Option<&'static str> {
160 match name {
161 "shell" => Some("ctx_shell"),
162 _ => None,
163 }
164}
165
166#[must_use]
169pub fn build_report(
170 advertised: &[rmcp::model::Tool],
171 usage: &CostStore,
172 rules: &[RulesFileCost],
173 duplicates: Vec<(String, usize)>,
174 instruction_tokens: usize,
175 tool_profile: String,
176 knowledge: KnowledgeHealth,
177) -> ToolHealthReport {
178 let total_recorded_calls: u64 = usage.tools.values().map(|t| t.total_calls).sum();
179 let has_usage_data = total_recorded_calls > 0;
180
181 let mut tools: Vec<ToolEntry> = advertised
182 .iter()
183 .map(|t| {
184 let name = t.name.as_ref().to_string();
185 let schema_tokens = tool_tokens(t);
186 let (calls, last_used) = usage
187 .tools
188 .get(&name)
189 .or_else(|| resolve_alias(&name).and_then(|a| usage.tools.get(a)))
190 .map_or((0, None), |c| (c.total_calls, c.last_used.clone()));
191 let status = classify(has_usage_data, calls, schema_tokens, total_recorded_calls);
192 let action = action_for(status, calls, schema_tokens);
193 ToolEntry {
194 name,
195 schema_tokens,
196 calls,
197 last_used,
198 status,
199 action,
200 value_per_1k_tokens: value_per_1k(calls, schema_tokens),
201 }
202 })
203 .collect();
204 tools.sort_by(|a, b| a.name.cmp(&b.name));
205
206 let tool_schema_tokens: usize = tools.iter().map(|t| t.schema_tokens).sum();
207 let unused_tools = tools
208 .iter()
209 .filter(|t| t.status == ToolStatus::Unused)
210 .count();
211 let unused_tool_tokens = tools
212 .iter()
213 .filter(|t| t.status == ToolStatus::Unused)
214 .map(|t| t.schema_tokens)
215 .sum();
216
217 let low_value = |t: &&ToolEntry| matches!(t.status, ToolStatus::Unused | ToolStatus::LowUse);
220 let disable_candidates: Vec<String> = tools
221 .iter()
222 .filter(low_value)
223 .map(|t| t.name.clone())
224 .collect();
225 let reclaimable_tokens: usize = tools
226 .iter()
227 .filter(low_value)
228 .map(|t| t.schema_tokens)
229 .sum();
230 let disable_action = if disable_candidates.is_empty() {
231 String::new()
232 } else {
233 format!(
234 "consider disabling {} low-value tool(s) to reclaim {reclaimable_tokens} tok/session: {} — apply via `disabled_tools` in config or a leaner `tool_profile`",
235 disable_candidates.len(),
236 disable_candidates.join(", ")
237 )
238 };
239
240 let dup_clients: std::collections::HashSet<&str> =
241 duplicates.iter().map(|(c, _)| c.as_str()).collect();
242 let rules_out: Vec<RuleEntry> = rules
243 .iter()
244 .map(|r| {
245 let is_dup = r.carries_full && r.clients.iter().any(|c| dup_clients.contains(c));
246 let action = if is_dup {
247 "duplicate full lean-ctx source — run `lean-ctx rules dedup --apply`".to_string()
248 } else {
249 String::new()
250 };
251 RuleEntry {
252 path: r.path.clone(),
253 file_tokens: r.file_tokens,
254 lean_ctx_tokens: r.lean_ctx_tokens,
255 carries_full: r.carries_full,
256 clients: r.clients.iter().map(|c| (*c).to_string()).collect(),
257 action,
258 }
259 })
260 .collect();
261
262 let rules_tokens: usize = rules_out.iter().map(|r| r.file_tokens).sum();
263 let fixed_total_tokens = tool_schema_tokens + instruction_tokens + rules_tokens;
264
265 ToolHealthReport {
266 tool_profile,
267 advertised_tools: tools.len(),
268 tool_schema_tokens,
269 instruction_tokens,
270 rules_tokens,
271 fixed_total_tokens,
272 has_usage_data,
273 total_recorded_calls,
274 unused_tools,
275 unused_tool_tokens,
276 disable_candidates,
277 reclaimable_tokens,
278 disable_action,
279 footprint_note: None,
280 tools,
281 rules: rules_out,
282 duplicate_clients: duplicates,
283 knowledge,
284 }
285}
286
287fn latest_footprint_note() -> Option<String> {
291 use crate::core::eval_ab::footprint::{FootprintReport, InjectedElement};
292
293 let dir = crate::core::data_dir::lean_ctx_data_dir()
294 .ok()?
295 .join("eval");
296 let mut artifacts: Vec<(std::time::SystemTime, std::path::PathBuf)> = std::fs::read_dir(&dir)
297 .ok()?
298 .flatten()
299 .filter_map(|e| {
300 let path = e.path();
301 let name = path.file_name()?.to_str()?.to_string();
302 let is_json = path
303 .extension()
304 .and_then(|x| x.to_str())
305 .is_some_and(|x| x.eq_ignore_ascii_case("json"));
306 if name.starts_with("footprint-report-v1_") && is_json {
307 Some((e.metadata().ok()?.modified().ok()?, path))
308 } else {
309 None
310 }
311 })
312 .collect();
313 artifacts.sort_by_key(|a| a.0);
314 let (_, path) = artifacts.last()?;
315
316 let raw = std::fs::read_to_string(path).ok()?;
317 let report: FootprintReport = serde_json::from_str(&raw).ok()?;
318 let schemas = report
319 .elements
320 .iter()
321 .find(|e| e.element == InjectedElement::ToolSchemas)?;
322 let verdict = if schemas.prune_recommended {
323 "PRUNE-recommended"
324 } else {
325 "earns its cost"
326 };
327 Some(format!(
328 "footprint eval ({}): tool schemas {verdict} (Δpass {:+.0}%, cost {} tok)",
329 report.suite,
330 schemas.pass_rate_delta * 100.0,
331 schemas.token_cost
332 ))
333}
334
335fn resolve_tool_profile() -> String {
336 let cfg = crate::core::config::Config::load();
337 if crate::server::tool_visibility::explicit_profile(&cfg) {
338 cfg.tool_profile_effective().as_str().to_string()
339 } else {
340 "lean (default)".to_string()
341 }
342}
343
344fn knowledge_health(project: &Path) -> KnowledgeHealth {
345 let Some(knowledge) =
346 crate::core::knowledge::ProjectKnowledge::load(&project.to_string_lossy())
347 else {
348 return KnowledgeHealth::default();
349 };
350 let total = knowledge.facts.len();
351 let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
352 let now = chrono::Utc::now();
353 let stale = current
354 .iter()
355 .filter(|f| (now - f.created_at).num_days() > STALE_FACT_DAYS && f.retrieval_count == 0)
356 .count();
357 let action = if stale > 0 {
358 format!(
359 "{stale} stale fact(s) (>{STALE_FACT_DAYS}d, never retrieved) — review with `lean-ctx knowledge`"
360 )
361 } else {
362 String::new()
363 };
364 KnowledgeHealth {
365 total_facts: total,
366 active_facts: current.len(),
367 stale_facts: stale,
368 action,
369 }
370}
371
372#[must_use]
374pub fn compute(home: &Path, project: &Path) -> ToolHealthReport {
375 let advertised = crate::server::tool_visibility::advertised_tool_defs_default();
376 let usage = CostStore::load();
377 let rules = collect_rules_files(home, project);
378 let duplicates = duplicate_clients(&rules);
379 let instructions = crate::instructions::build_instructions(crate::tools::CrpMode::effective());
380 let instruction_tokens = crate::core::tokens::count_tokens(&instructions);
381 let knowledge = knowledge_health(project);
382 let mut report = build_report(
383 &advertised,
384 &usage,
385 &rules,
386 duplicates,
387 instruction_tokens,
388 resolve_tool_profile(),
389 knowledge,
390 );
391 report.footprint_note = latest_footprint_note();
392 report
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use crate::core::a2a::cost_attribution::ToolCost;
399
400 fn tool(name: &'static str) -> rmcp::model::Tool {
401 crate::tool_defs::tool_def(
402 name,
403 "a representative description used to give the schema some token weight",
404 serde_json::json!({
405 "type": "object",
406 "properties": { "path": { "type": "string", "description": "file path" } }
407 }),
408 )
409 }
410
411 fn usage_with(calls: &[(&str, u64)]) -> CostStore {
412 let mut store = CostStore::default();
413 for (name, n) in calls {
414 store.tools.insert(
415 (*name).to_string(),
416 ToolCost {
417 tool_name: (*name).to_string(),
418 total_calls: *n,
419 last_used: Some("2026-06-01T00:00:00+00:00".to_string()),
420 ..Default::default()
421 },
422 );
423 }
424 store
425 }
426
427 #[test]
428 fn classify_unknown_without_usage_history() {
429 assert_eq!(classify(false, 0, 500, 0), ToolStatus::Unknown);
430 assert_eq!(classify(false, 9, 500, 0), ToolStatus::Unknown);
431 }
432
433 #[test]
434 fn classify_unused_when_history_exists_but_tool_never_called() {
435 assert_eq!(classify(true, 0, 500, 1000), ToolStatus::Unused);
436 }
437
438 #[test]
439 fn classify_low_use_for_expensive_rarely_called_tool() {
440 assert_eq!(classify(true, 1, 400, 10_000), ToolStatus::LowUse);
442 assert_eq!(classify(true, 1, 50, 10_000), ToolStatus::Active);
444 }
445
446 #[test]
447 fn classify_active_for_well_used_tool() {
448 assert_eq!(classify(true, 500, 400, 1000), ToolStatus::Active);
449 }
450
451 #[test]
452 fn build_report_flags_unused_and_sorts_tools() {
453 let advertised = vec![tool("ctx_search"), tool("ctx_read"), tool("ctx_shell")];
454 let usage = usage_with(&[("ctx_read", 40)]);
456 let report = build_report(
457 &advertised,
458 &usage,
459 &[],
460 Vec::new(),
461 100,
462 "lean (default)".to_string(),
463 KnowledgeHealth::default(),
464 );
465
466 assert!(report.has_usage_data);
467 assert_eq!(report.total_recorded_calls, 40);
468 let names: Vec<&str> = report.tools.iter().map(|t| t.name.as_str()).collect();
470 assert_eq!(names, vec!["ctx_read", "ctx_search", "ctx_shell"]);
471 assert_eq!(report.unused_tools, 2);
473 assert!(report.unused_tool_tokens > 0);
474 let read = report.tools.iter().find(|t| t.name == "ctx_read").unwrap();
475 assert_eq!(read.status, ToolStatus::Active);
476 assert!(read.last_used.is_some());
477 assert_eq!(
479 report.fixed_total_tokens,
480 report.tool_schema_tokens + 100 + report.rules_tokens
481 );
482 }
483
484 #[test]
485 fn value_per_1k_rewards_cheap_well_used_tools() {
486 assert!(value_per_1k(100, 50) > value_per_1k(100, 500));
487 assert_eq!(value_per_1k(0, 100), 0.0);
488 assert_eq!(value_per_1k(10, 0), 0.0, "no schema cost → no division");
489 }
490
491 #[test]
492 fn build_report_recommends_disabling_low_value_tools() {
493 let advertised = vec![tool("ctx_read"), tool("ctx_search"), tool("ctx_shell")];
494 let usage = usage_with(&[("ctx_read", 40)]);
496 let report = build_report(
497 &advertised,
498 &usage,
499 &[],
500 Vec::new(),
501 0,
502 "lean (default)".to_string(),
503 KnowledgeHealth::default(),
504 );
505 assert!(
506 report
507 .disable_candidates
508 .contains(&"ctx_search".to_string())
509 );
510 assert!(report.disable_candidates.contains(&"ctx_shell".to_string()));
511 assert!(
512 !report.disable_candidates.contains(&"ctx_read".to_string()),
513 "an active tool is never a disable candidate"
514 );
515 assert!(report.reclaimable_tokens > 0);
516 assert!(report.disable_action.contains("consider disabling"));
517 assert!(
518 report.footprint_note.is_none(),
519 "the pure builder never reads disk artifacts"
520 );
521 let read = report.tools.iter().find(|t| t.name == "ctx_read").unwrap();
522 assert!(read.value_per_1k_tokens > 0.0);
523 }
524
525 #[test]
526 fn build_report_unknown_status_without_history() {
527 let advertised = vec![tool("ctx_read")];
528 let report = build_report(
529 &advertised,
530 &CostStore::default(),
531 &[],
532 Vec::new(),
533 0,
534 "lean (default)".to_string(),
535 KnowledgeHealth::default(),
536 );
537 assert!(!report.has_usage_data);
538 assert_eq!(report.unused_tools, 0, "never flag rot without history");
539 assert_eq!(report.tools[0].status, ToolStatus::Unknown);
540 }
541
542 #[test]
543 fn build_report_marks_duplicate_rules() {
544 let rules = vec![
545 RulesFileCost {
546 path: "a/.cursor/rules/lean-ctx.mdc".into(),
547 file_tokens: 200,
548 lean_ctx_tokens: 200,
549 carries_full: true,
550 clients: vec!["cursor"],
551 },
552 RulesFileCost {
553 path: "a/.cursorrules".into(),
554 file_tokens: 150,
555 lean_ctx_tokens: 150,
556 carries_full: true,
557 clients: vec!["cursor"],
558 },
559 ];
560 let dups = duplicate_clients(&rules);
561 let report = build_report(
562 &[],
563 &CostStore::default(),
564 &rules,
565 dups,
566 0,
567 "lean (default)".to_string(),
568 KnowledgeHealth::default(),
569 );
570 assert_eq!(report.rules.len(), 2);
571 assert!(
572 report.rules.iter().all(|r| r.action.contains("dedup")),
573 "both cursor full sources flagged as duplicates"
574 );
575 assert_eq!(report.rules_tokens, 350);
576 }
577
578 #[test]
579 fn compute_smoke_runs_and_counts_advertised_tools() {
580 let _iso = crate::core::data_dir::isolated_data_dir();
588 let tmp = tempfile::tempdir().unwrap();
589 let report = compute(tmp.path(), tmp.path());
590 let expected = crate::server::tool_visibility::advertised_tool_defs_default().len();
591 assert_eq!(report.advertised_tools, expected);
592 assert!(report.tool_schema_tokens > 0);
593 assert!(report.fixed_total_tokens >= report.tool_schema_tokens);
594 }
595
596 #[test]
598 fn shell_alias_inherits_ctx_shell_usage_838() {
599 let shell_tool = tool("shell");
600 let mut usage = CostStore::default();
601 let tc = ToolCost {
602 total_calls: 42,
603 last_used: Some("2026-07-15".to_string()),
604 ..ToolCost::default()
605 };
606 usage.tools.insert("ctx_shell".to_string(), tc);
607 let report = build_report(
608 &[shell_tool],
609 &usage,
610 &[],
611 Vec::new(),
612 0,
613 "lean".to_string(),
614 KnowledgeHealth::default(),
615 );
616 assert_eq!(report.tools[0].name, "shell");
617 assert_eq!(
618 report.tools[0].calls, 42,
619 "shell must inherit ctx_shell calls"
620 );
621 assert_ne!(
622 report.tools[0].status,
623 ToolStatus::Unused,
624 "shell must not be flagged unused"
625 );
626 }
627}