1use crate::core::security_events::SecurityCounts;
6use crate::core::wrapped::format_tokens;
7
8use super::snapshot::ValueSnapshot;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct Style {
15 pub color: bool,
16 pub unicode: bool,
17}
18
19impl Style {
20 pub const PLAIN: Self = Self {
21 color: false,
22 unicode: true,
23 };
24
25 pub fn from_env() -> Self {
28 Self {
29 color: std::env::var_os("NO_COLOR").is_none(),
30 unicode: std::env::var_os("LEAN_CTX_ASCII").is_none(),
31 }
32 }
33
34 pub(crate) fn mark(self) -> &'static str {
35 if self.unicode { "◆" } else { "*" }
36 }
37
38 pub(crate) fn shield(self) -> &'static str {
39 if self.unicode { "⛨" } else { "sec" }
40 }
41
42 pub(crate) fn minus(self) -> &'static str {
43 if self.unicode { "−" } else { "-" }
44 }
45
46 pub(crate) fn sep(self) -> &'static str {
47 if self.unicode { " · " } else { " | " }
48 }
49}
50
51pub fn one_line(snap: &ValueSnapshot, style: Style) -> Option<String> {
54 if snap.is_empty() {
55 return None;
56 }
57 let mut parts = Vec::new();
58 if snap.tokens_saved > 0 {
59 parts.push(format!(
60 "{}{} tok",
61 style.minus(),
62 format_tokens(snap.tokens_saved)
63 ));
64 }
65 if snap.cache_hits > 0 {
66 parts.push(format!("{} cached", snap.cache_hits));
67 }
68 let security = snap.security.total();
69 if security > 0 {
70 parts.push(format!("{} {security}", style.shield()));
71 }
72 let body = format!("{} lean-ctx {}", style.mark(), parts.join(style.sep()));
73 Some(if style.color {
74 format!("\x1b[2m{body}\x1b[0m")
75 } else {
76 body
77 })
78}
79
80const BRAND_PILL: &str = "1;38;2;17;22;38;48;2;102;140;255"; const BRAND_LABEL: &str = "1;38;2;102;140;255"; const BRAND_VALUE: &str = "1;38;2;241;243;255"; const BRAND_DETAIL: &str = "38;2;175;184;208"; const BRAND_RULE: &str = "38;2;53;62;90"; fn paint(style: Style, sgr: &str, text: &str) -> String {
88 if style.color {
89 format!("\x1b[{sgr}m{text}\x1b[0m")
90 } else {
91 text.to_string()
92 }
93}
94
95pub fn brand_line(snap: &ValueSnapshot, style: Style) -> Option<String> {
101 let sources = snap.files_read + snap.commands_run;
102 if snap.is_empty() && sources == 0 {
103 return None;
104 }
105 let sep = style.sep();
106 let rule = if style.unicode { " │ " } else { " | " };
107 let group = |label: &str, value: String, details: &[String]| {
108 let mut out = format!(
109 "{} {}",
110 paint(style, BRAND_LABEL, label),
111 paint(style, BRAND_VALUE, &value)
112 );
113 for detail in details {
114 out.push_str(&paint(style, BRAND_DETAIL, &format!("{sep}{detail}")));
115 }
116 out
117 };
118 let mut groups = Vec::new();
119 if sources > 0 {
120 let mut parts = Vec::new();
121 if snap.files_read > 0 {
122 parts.push(plural(snap.files_read, "file", "files"));
123 }
124 if snap.commands_run > 0 {
125 parts.push(plural(snap.commands_run, "command", "commands"));
126 }
127 let (value, details) = parts.split_first().expect("sources > 0 names one part");
128 groups.push(group("SELECT", value.clone(), details));
129 }
130 let security = snap.security.total();
131 if security > 0 {
132 groups.push(group(
133 "CONTROL",
134 format!("{} {security} enforced", style.shield()),
135 &[],
136 ));
137 }
138 if snap.tokens_saved > 0 {
139 let mut details = Vec::new();
140 if let Some(pct) = snap.saved_pct() {
141 details.push(format!("{pct:.0}% leaner"));
142 }
143 if snap.cache_hits > 0 {
144 details.push(format!("{} cached", snap.cache_hits));
145 }
146 let value = format!(
147 "{}{} kept out",
148 style.minus(),
149 format_tokens(snap.tokens_saved)
150 );
151 groups.push(group("TOKENS", value, &details));
152 }
153 let brand = format!("{} LeanCTX", style.mark());
154 let lead = if style.color {
155 paint(style, BRAND_PILL, &format!(" {brand} "))
156 } else {
157 brand
158 };
159 let rule = paint(style, BRAND_RULE, rule);
160 Some(format!("{lead}{rule}{}", groups.join(&rule)))
161}
162
163pub fn compact(snap: &ValueSnapshot, style: Style) -> Option<String> {
166 if snap.is_empty() {
167 return None;
168 }
169 let mut out = style.mark().to_string();
170 if snap.tokens_saved > 0 {
171 out.push_str(&format!(
172 " {}{} tok",
173 style.minus(),
174 format_tokens(snap.tokens_saved)
175 ));
176 }
177 let security = snap.security.total();
178 if security > 0 {
179 out.push_str(&format!(" {} {security}", style.shield()));
180 }
181 (out.len() > style.mark().len()).then_some(out)
183}
184
185fn plural(n: u64, one: &str, many: &str) -> String {
186 format!("{n} {}", if n == 1 { one } else { many })
187}
188
189pub fn security_phrases(counts: &SecurityCounts) -> Vec<String> {
192 let mut out = Vec::new();
193 if counts.secrets_redacted > 0 {
194 out.push(format!(
195 "{} kept out of context",
196 plural(counts.secrets_redacted, "secret", "secrets")
197 ));
198 }
199 if counts.shell_blocked > 0 {
200 out.push(format!(
201 "{} blocked",
202 plural(counts.shell_blocked, "risky command", "risky commands")
203 ));
204 }
205 if counts.path_blocked > 0 {
206 out.push(format!(
207 "{} outside the project blocked",
208 plural(counts.path_blocked, "path", "paths")
209 ));
210 }
211 if counts.injection_flagged > 0 {
212 out.push(format!(
213 "{} flagged",
214 plural(
215 counts.injection_flagged,
216 "prompt-injection pattern",
217 "prompt-injection patterns"
218 )
219 ));
220 }
221 if counts.pii_redacted > 0 {
222 out.push(format!(
223 "{} kept out of context",
224 plural(
225 counts.pii_redacted,
226 "personal data value",
227 "personal data values"
228 )
229 ));
230 }
231 if counts.content_withheld > 0 {
232 out.push(format!(
233 "{} withheld",
234 plural(counts.content_withheld, "source", "sources")
235 ));
236 }
237 if counts.coverage_incomplete > 0 {
238 out.push(format!(
239 "{} not fully inspected",
240 plural(counts.coverage_incomplete, "source", "sources")
241 ));
242 }
243 out
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn snap(saved: u64, cache: u64, secrets: u64) -> ValueSnapshot {
251 let mut s = ValueSnapshot {
252 tokens_saved: saved,
253 tokens_input: saved * 2,
254 cache_hits: cache,
255 ..ValueSnapshot::default()
256 };
257 s.security.secrets_redacted = secrets;
258 s
259 }
260
261 #[test]
262 fn nothing_measured_renders_nothing() {
263 assert_eq!(one_line(&ValueSnapshot::default(), Style::PLAIN), None);
264 }
265
266 #[test]
267 fn plain_unicode_line() {
268 assert_eq!(
269 one_line(&snap(1_200_000, 41, 3), Style::PLAIN).unwrap(),
270 "◆ lean-ctx −1.2M tok · 41 cached · ⛨ 3"
271 );
272 }
273
274 #[test]
275 fn ascii_fallback_has_no_multibyte_glyphs() {
276 let style = Style {
277 color: false,
278 unicode: false,
279 };
280 let line = one_line(&snap(312_000, 0, 1), style).unwrap();
281 assert!(line.is_ascii(), "{line}");
282 assert_eq!(line, "* lean-ctx -312.0K tok | sec 1");
283 }
284
285 #[test]
286 fn color_is_dim_and_reset() {
287 let style = Style {
288 color: true,
289 unicode: true,
290 };
291 let line = one_line(&snap(5_000, 0, 0), style).unwrap();
292 assert!(line.starts_with("\x1b[2m") && line.ends_with("\x1b[0m"));
293 }
294
295 #[test]
296 fn compact_is_short_and_uncoloured() {
297 let style = Style {
298 color: true,
299 unicode: true,
300 };
301 assert_eq!(
302 compact(&snap(1_200_000, 41, 3), style).unwrap(),
303 "◆ −1.2M tok ⛨ 3"
304 );
305 let ascii = Style {
306 color: false,
307 unicode: false,
308 };
309 assert_eq!(compact(&snap(0, 0, 2), ascii).unwrap(), "* sec 2");
310 assert_eq!(compact(&snap(0, 9, 0), Style::PLAIN), None);
311 assert_eq!(compact(&ValueSnapshot::default(), Style::PLAIN), None);
312 }
313
314 #[test]
315 fn brand_line_groups_select_control_and_tokens() {
316 let mut full = snap(1_200_000, 41, 3);
317 full.files_read = 12;
318 full.commands_run = 30;
319 assert_eq!(
320 brand_line(&full, Style::PLAIN).unwrap(),
321 "◆ LeanCTX │ SELECT 12 files · 30 commands │ CONTROL ⛨ 3 enforced │ TOKENS −1.2M kept out · 50% leaner · 41 cached"
322 );
323 assert_eq!(
324 brand_line(&snap(0, 0, 2), Style::PLAIN).unwrap(),
325 "◆ LeanCTX │ CONTROL ⛨ 2 enforced"
326 );
327 let read_only = ValueSnapshot {
329 files_read: 1,
330 ..ValueSnapshot::default()
331 };
332 assert_eq!(
333 brand_line(&read_only, Style::PLAIN).unwrap(),
334 "◆ LeanCTX │ SELECT 1 file"
335 );
336 assert_eq!(brand_line(&ValueSnapshot::default(), Style::PLAIN), None);
337 }
338
339 #[test]
340 fn brand_line_colour_and_ascii_fallbacks() {
341 let colour = Style {
342 color: true,
343 unicode: true,
344 };
345 let line = brand_line(&snap(5_000, 0, 0), colour).unwrap();
346 assert!(line.starts_with("\x1b[1;38;2;17;22;38;48;2;102;140;255m ◆ LeanCTX \x1b[0m"));
347 assert!(line.ends_with("\x1b[0m"));
348 let ascii = Style {
349 color: false,
350 unicode: false,
351 };
352 let line = brand_line(&snap(312_000, 2, 1), ascii).unwrap();
353 assert!(line.is_ascii(), "{line}");
354 assert_eq!(
355 line,
356 "* LeanCTX | CONTROL sec 1 enforced | TOKENS -312.0K kept out | 50% leaner | 2 cached"
357 );
358 }
359
360 #[test]
361 fn security_only_session_still_shows() {
362 let line = one_line(&snap(0, 0, 2), Style::PLAIN).unwrap();
363 assert_eq!(line, "◆ lean-ctx ⛨ 2");
364 }
365
366 #[test]
367 fn phrases_never_claim_neutralization() {
368 let counts = SecurityCounts {
369 secrets_redacted: 1,
370 shell_blocked: 2,
371 path_blocked: 1,
372 injection_flagged: 3,
373 ..SecurityCounts::ZERO
374 };
375 let text = security_phrases(&counts).join(", ");
376 assert_eq!(
377 text,
378 "1 secret kept out of context, 2 risky commands blocked, \
379 1 path outside the project blocked, 3 prompt-injection patterns flagged"
380 );
381 assert!(!text.contains("neutraliz"));
382 }
383
384 #[test]
385 fn gateway_phrases_say_what_happened_without_overclaiming() {
386 let counts = SecurityCounts {
387 pii_redacted: 2,
388 content_withheld: 1,
389 coverage_incomplete: 1,
390 ..SecurityCounts::ZERO
391 };
392 assert_eq!(
393 security_phrases(&counts).join(", "),
394 "2 personal data values kept out of context, 1 source withheld, \
395 1 source not fully inspected"
396 );
397 }
398}