lean_ctx/core/gain/
bridge_status.rs1use serde::Serialize;
11
12const INTROSPECT_MAX_AGE_SECS: u64 = 86_400;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BridgeEngagement {
21 ProxyDown,
23 NoRequests,
26 Engaged,
28}
29
30#[derive(Debug, Clone, Serialize)]
32pub struct BridgeStatus {
33 pub engagement: BridgeEngagement,
34 pub proxy_running: bool,
35 pub proxy_configured: bool,
37 pub total_requests: u64,
40 pub tool_count: usize,
42}
43
44#[must_use]
49pub fn classify(proxy_running: bool, total_requests: u64) -> BridgeEngagement {
50 match (proxy_running, total_requests > 0) {
51 (false, _) => BridgeEngagement::ProxyDown,
52 (true, false) => BridgeEngagement::NoRequests,
53 (true, true) => BridgeEngagement::Engaged,
54 }
55}
56
57impl BridgeStatus {
58 #[must_use]
64 pub fn detect() -> Self {
65 let proxy_configured = crate::core::config::Config::load().proxy_enabled == Some(true);
66 let proxy_running = crate::core::proxy_endpoint::configured_proxy_live();
67 let total_requests = persisted_request_count(INTROSPECT_MAX_AGE_SECS);
68 let tool_count = crate::core::tool_catalog::tool_count();
69 let engagement = classify(proxy_running, total_requests);
70 Self {
71 engagement,
72 proxy_running,
73 proxy_configured,
74 total_requests,
75 tool_count,
76 }
77 }
78
79 #[must_use]
83 pub fn summary_line(&self) -> String {
84 match self.engagement {
85 BridgeEngagement::Engaged => format!(
86 "Bridge: connected — {} tools, {} requests intercepted",
87 self.tool_count, self.total_requests
88 ),
89 BridgeEngagement::NoRequests => format!(
90 "Bridge: proxy up, 0 requests intercepted — {} tools exposed (route the editor through lean-ctx)",
91 self.tool_count
92 ),
93 BridgeEngagement::ProxyDown if !self.proxy_configured => format!(
94 "Bridge: OFF — proxy not configured (enable: lean-ctx proxy enable); savings cannot be measured ({} tools registered)",
95 self.tool_count
96 ),
97 BridgeEngagement::ProxyDown => format!(
98 "Bridge: OFF — proxy not reachable; savings cannot be measured ({} tools registered)",
99 self.tool_count
100 ),
101 }
102 }
103
104 #[must_use]
107 pub fn zero_savings_reason(&self, tokens_saved: u64) -> Option<String> {
108 if tokens_saved > 0 {
109 return None;
110 }
111 Some(match self.engagement {
112 BridgeEngagement::ProxyDown => "saved=0 because the bridge is OFF — the proxy is not \
113 running, so no requests are intercepted. Start it (`lean-ctx serve`) and confirm \
114 `/lean-ctx` shows connected; reads/commands will then record savings."
115 .to_string(),
116 BridgeEngagement::NoRequests => {
117 "saved=0 because the proxy has not intercepted any LLM \
118 request yet. Verify your editor's mcp.json routes through lean-ctx (`/lean-ctx` → \
119 connected), then retry."
120 .to_string()
121 }
122 BridgeEngagement::Engaged => "saved=0 is real for this window — the bridge is engaged \
123 but no compressible context was seen yet (e.g. only cold first reads). Re-run a \
124 read to populate the cache and savings will appear."
125 .to_string(),
126 })
127 }
128}
129
130fn persisted_request_count(max_age_secs: u64) -> u64 {
133 crate::core::proxy_introspect::load_persisted(max_age_secs)
134 .as_ref()
135 .and_then(|v| v.get("cumulative"))
136 .and_then(|c| c.get("total_requests"))
137 .and_then(serde_json::Value::as_u64)
138 .unwrap_or(0)
139}
140
141#[cfg(test)]
142pub mod tests {
143 use super::*;
144
145 #[test]
146 fn classify_proxy_down_when_not_running() {
147 assert_eq!(classify(false, 0), BridgeEngagement::ProxyDown);
148 assert_eq!(classify(false, 1234), BridgeEngagement::ProxyDown);
150 }
151
152 #[test]
153 fn classify_no_requests_when_running_but_idle() {
154 assert_eq!(classify(true, 0), BridgeEngagement::NoRequests);
155 }
156
157 #[test]
158 fn classify_engaged_when_running_with_traffic() {
159 assert_eq!(classify(true, 1), BridgeEngagement::Engaged);
160 assert_eq!(classify(true, 50_000), BridgeEngagement::Engaged);
161 }
162
163 #[test]
164 fn zero_savings_reason_is_none_when_savings_present() {
165 let status = BridgeStatus {
166 engagement: BridgeEngagement::Engaged,
167 proxy_running: true,
168 proxy_configured: true,
169 total_requests: 10,
170 tool_count: 69,
171 };
172 assert!(status.zero_savings_reason(42).is_none());
173 }
174
175 #[test]
176 fn zero_savings_reason_distinguishes_off_from_real_zero() {
177 let off = BridgeStatus {
178 engagement: BridgeEngagement::ProxyDown,
179 proxy_running: false,
180 proxy_configured: true,
181 total_requests: 0,
182 tool_count: 69,
183 };
184 let real = BridgeStatus {
185 engagement: BridgeEngagement::Engaged,
186 proxy_running: true,
187 proxy_configured: true,
188 total_requests: 10,
189 tool_count: 69,
190 };
191 let off_msg = off.zero_savings_reason(0).expect("off has a reason");
192 let real_msg = real.zero_savings_reason(0).expect("engaged has a reason");
193 assert!(off_msg.contains("bridge is OFF"), "got: {off_msg}");
194 assert!(real_msg.contains("is real"), "got: {real_msg}");
195 assert_ne!(off_msg, real_msg, "off and real-zero must differ");
196 }
197
198 #[test]
199 fn summary_line_reflects_engagement() {
200 let engaged = BridgeStatus {
201 engagement: BridgeEngagement::Engaged,
202 proxy_running: true,
203 proxy_configured: true,
204 total_requests: 7,
205 tool_count: 69,
206 };
207 let line = engaged.summary_line();
208 assert!(line.contains("connected"));
209 assert!(line.contains("69 tools"));
210 assert!(line.contains("7 requests"));
211 }
212}