lean_ctx/core/gain/
bridge_status.rs1use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
11use std::time::Duration;
12
13use serde::Serialize;
14
15const INTROSPECT_MAX_AGE_SECS: u64 = 86_400;
19
20const PROXY_PROBE_TIMEOUT: Duration = Duration::from_millis(150);
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum BridgeEngagement {
28 ProxyDown,
30 NoRequests,
33 Engaged,
35}
36
37#[derive(Debug, Clone, Serialize)]
39pub struct BridgeStatus {
40 pub engagement: BridgeEngagement,
41 pub proxy_running: bool,
42 pub total_requests: u64,
45 pub tool_count: usize,
47}
48
49#[must_use]
54pub fn classify(proxy_running: bool, total_requests: u64) -> BridgeEngagement {
55 match (proxy_running, total_requests > 0) {
56 (false, _) => BridgeEngagement::ProxyDown,
57 (true, false) => BridgeEngagement::NoRequests,
58 (true, true) => BridgeEngagement::Engaged,
59 }
60}
61
62impl BridgeStatus {
63 #[must_use]
67 pub fn detect() -> Self {
68 let proxy_running = probe_proxy(crate::proxy_setup::default_port());
69 let total_requests = persisted_request_count(INTROSPECT_MAX_AGE_SECS);
70 let tool_count = crate::server::registry::tool_count();
71 let engagement = classify(proxy_running, total_requests);
72 Self {
73 engagement,
74 proxy_running,
75 total_requests,
76 tool_count,
77 }
78 }
79
80 #[must_use]
84 pub fn summary_line(&self) -> String {
85 match self.engagement {
86 BridgeEngagement::Engaged => format!(
87 "Bridge: connected — {} tools, {} requests intercepted",
88 self.tool_count, self.total_requests
89 ),
90 BridgeEngagement::NoRequests => format!(
91 "Bridge: proxy up, 0 requests intercepted — {} tools exposed (route the editor through lean-ctx)",
92 self.tool_count
93 ),
94 BridgeEngagement::ProxyDown => format!(
95 "Bridge: OFF — proxy not reachable; savings cannot be measured ({} tools registered)",
96 self.tool_count
97 ),
98 }
99 }
100
101 #[must_use]
104 pub fn zero_savings_reason(&self, tokens_saved: u64) -> Option<String> {
105 if tokens_saved > 0 {
106 return None;
107 }
108 Some(match self.engagement {
109 BridgeEngagement::ProxyDown => "saved=0 because the bridge is OFF — the proxy is not \
110 running, so no requests are intercepted. Start it (`lean-ctx serve`) and confirm \
111 `/lean-ctx` shows connected; reads/commands will then record savings."
112 .to_string(),
113 BridgeEngagement::NoRequests => {
114 "saved=0 because the proxy has not intercepted any LLM \
115 request yet. Verify your editor's mcp.json routes through lean-ctx (`/lean-ctx` → \
116 connected), then retry."
117 .to_string()
118 }
119 BridgeEngagement::Engaged => "saved=0 is real for this window — the bridge is engaged \
120 but no compressible context was seen yet (e.g. only cold first reads). Re-run a \
121 read to populate the cache and savings will appear."
122 .to_string(),
123 })
124 }
125}
126
127fn probe_proxy(port: u16) -> bool {
129 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
130 TcpStream::connect_timeout(&addr, PROXY_PROBE_TIMEOUT).is_ok()
131}
132
133fn persisted_request_count(max_age_secs: u64) -> u64 {
136 crate::proxy::introspect::load_persisted(max_age_secs)
137 .as_ref()
138 .and_then(|v| v.get("cumulative"))
139 .and_then(|c| c.get("total_requests"))
140 .and_then(serde_json::Value::as_u64)
141 .unwrap_or(0)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn classify_proxy_down_when_not_running() {
150 assert_eq!(classify(false, 0), BridgeEngagement::ProxyDown);
151 assert_eq!(classify(false, 1234), BridgeEngagement::ProxyDown);
153 }
154
155 #[test]
156 fn classify_no_requests_when_running_but_idle() {
157 assert_eq!(classify(true, 0), BridgeEngagement::NoRequests);
158 }
159
160 #[test]
161 fn classify_engaged_when_running_with_traffic() {
162 assert_eq!(classify(true, 1), BridgeEngagement::Engaged);
163 assert_eq!(classify(true, 50_000), BridgeEngagement::Engaged);
164 }
165
166 #[test]
167 fn zero_savings_reason_is_none_when_savings_present() {
168 let status = BridgeStatus {
169 engagement: BridgeEngagement::Engaged,
170 proxy_running: true,
171 total_requests: 10,
172 tool_count: 69,
173 };
174 assert!(status.zero_savings_reason(42).is_none());
175 }
176
177 #[test]
178 fn zero_savings_reason_distinguishes_off_from_real_zero() {
179 let off = BridgeStatus {
180 engagement: BridgeEngagement::ProxyDown,
181 proxy_running: false,
182 total_requests: 0,
183 tool_count: 69,
184 };
185 let real = BridgeStatus {
186 engagement: BridgeEngagement::Engaged,
187 proxy_running: 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 total_requests: 7,
204 tool_count: 69,
205 };
206 let line = engaged.summary_line();
207 assert!(line.contains("connected"));
208 assert!(line.contains("69 tools"));
209 assert!(line.contains("7 requests"));
210 }
211}