1pub(crate) fn print_savings(original: usize, sent: usize) {
2 let footer = crate::core::protocol::format_savings(original, sent);
3 if !footer.is_empty() {
4 println!("{footer}");
5 }
6}
7
8#[cfg(unix)]
10pub(crate) fn filter_daemon_output(text: &str) -> String {
11 if crate::core::protocol::savings_footer_visible() {
12 return text.to_string();
13 }
14 text.lines()
15 .filter(|l| {
16 let t = l.trim();
17 !(t.starts_with('[')
18 && t.contains("tok")
19 && t.ends_with(']')
20 && (t.contains("tok saved") || t.contains("lean-ctx:") || t.contains("vs native")))
21 })
22 .collect::<Vec<_>>()
23 .join("\n")
24}
25
26pub fn load_shell_history_pub() -> Vec<String> {
27 load_shell_history()
28}
29
30pub(crate) fn load_shell_history() -> Vec<String> {
31 let shell = std::env::var("SHELL").unwrap_or_default();
32 let Some(home) = dirs::home_dir() else {
33 return Vec::new();
34 };
35
36 let history_file = if shell.contains("zsh") {
37 home.join(".zsh_history")
38 } else if shell.contains("fish") {
39 home.join(".local/share/fish/fish_history")
40 } else if cfg!(windows) && shell.is_empty() {
41 home.join("AppData")
42 .join("Roaming")
43 .join("Microsoft")
44 .join("Windows")
45 .join("PowerShell")
46 .join("PSReadLine")
47 .join("ConsoleHost_history.txt")
48 } else {
49 home.join(".bash_history")
50 };
51
52 match std::fs::read(&history_file) {
56 Ok(bytes) => String::from_utf8_lossy(&bytes)
57 .lines()
58 .filter_map(|l| {
59 let trimmed = l.trim();
60 if trimmed.starts_with(':') {
61 trimmed
62 .split(';')
63 .nth(1)
64 .map(std::string::ToString::to_string)
65 } else {
66 Some(trimmed.to_string())
67 }
68 })
69 .filter(|l| !l.is_empty())
70 .collect(),
71 Err(_) => Vec::new(),
72 }
73}
74
75pub(crate) fn daemon_fallback_hint() {
76 use std::sync::Once;
77 static HINT: Once = Once::new();
78 HINT.call_once(|| {
79 if crate::core::protocol::meta_visible() {
80 eprintln!("\x1b[2;33mhint: daemon not running — stats tracked locally (lean-ctx serve -d for full tracking)\x1b[0m");
81 }
82 });
83}
84
85pub(crate) fn format_tokens_cli(tokens: u64) -> String {
86 if tokens >= 1_000_000_000_000 {
87 format!("{:.2}T", tokens as f64 / 1_000_000_000_000.0)
88 } else if tokens >= 1_000_000_000 {
89 format!("{:.2}B", tokens as f64 / 1_000_000_000.0)
91 } else if tokens >= 1_000_000 {
92 format!("{:.1}M", tokens as f64 / 1_000_000.0)
93 } else if tokens >= 1_000 {
94 format!("{:.1}K", tokens as f64 / 1_000.0)
95 } else {
96 format!("{tokens}")
97 }
98}
99
100pub(crate) fn cli_track_read(
101 path: &str,
102 mode: &str,
103 original_tokens: usize,
104 output_tokens: usize,
105 output: &str,
106 duration: std::time::Duration,
107) {
108 crate::core::tool_lifecycle::record_file_read(
109 path,
110 mode,
111 original_tokens,
112 output_tokens,
113 false,
114 duration,
115 output,
116 );
117}
118
119pub(crate) fn cli_track_read_cached(
120 path: &str,
121 mode: &str,
122 original_tokens: usize,
123 output_tokens: usize,
124 output: &str,
125 duration: std::time::Duration,
126) {
127 crate::core::tool_lifecycle::record_file_read(
128 path,
129 mode,
130 original_tokens,
131 output_tokens,
132 true,
133 duration,
134 output,
135 );
136}
137
138pub(crate) fn cli_track_search(
139 modeled_baseline: usize,
140 observed_tokens: usize,
141 output_tokens: usize,
142 pattern: &str,
143 path: &str,
144 output: &str,
145 duration: std::time::Duration,
146) {
147 crate::core::tool_lifecycle::record_search(
148 modeled_baseline,
149 observed_tokens,
150 output_tokens,
151 pattern,
152 path,
153 duration,
154 output,
155 );
156}
157
158pub(crate) fn cli_track_tree(original_tokens: usize, output_tokens: usize) {
159 crate::core::tool_lifecycle::record_tree(original_tokens, output_tokens);
160}
161
162pub(crate) fn detect_project_root(args: &[String]) -> String {
163 let mut it = args.iter().peekable();
164 while let Some(a) = it.next() {
165 if let Some(v) = a.strip_prefix("--root=")
166 && !v.trim().is_empty()
167 {
168 return normalize_explicit_root(v);
169 }
170 if let Some(v) = a.strip_prefix("--project-root=")
171 && !v.trim().is_empty()
172 {
173 return normalize_explicit_root(v);
174 }
175 if (a == "--root" || a == "--project-root")
176 && let Some(v) = it.peek()
177 && !v.starts_with("--")
178 && !v.trim().is_empty()
179 {
180 return normalize_explicit_root(v);
181 }
182 }
183 if let Ok(root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
184 && !root.trim().is_empty()
185 {
186 return normalize_explicit_root(&root);
187 }
188 let cwd = std::env::current_dir()
189 .ok()
190 .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
191 promote_to_git_root(&cwd)
192}
193
194fn normalize_explicit_root(path: &str) -> String {
195 let expanded = expand_home(path.trim());
196 crate::core::index_paths::normalize_project_root(&expanded)
197}
198
199fn expand_home(path: &str) -> String {
200 if path == "~" {
201 return dirs::home_dir().map_or_else(
202 || path.to_string(),
203 |home| home.to_string_lossy().to_string(),
204 );
205 }
206 if let Some(rest) = path.strip_prefix("~/") {
207 return dirs::home_dir().map_or_else(
208 || path.to_string(),
209 |home| home.join(rest).to_string_lossy().to_string(),
210 );
211 }
212 path.to_string()
213}
214
215fn promote_to_git_root(path: &str) -> String {
216 let mut p = std::path::Path::new(path);
217 loop {
218 if p.join(".git").exists() {
219 return p.to_string_lossy().to_string();
220 }
221 match p.parent() {
222 Some(parent) => p = parent,
223 None => return path.to_string(),
224 }
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::{detect_project_root, format_tokens_cli, normalize_explicit_root};
231
232 #[test]
233 fn format_tokens_cli_scales_through_billions() {
234 assert_eq!(format_tokens_cli(742), "742");
235 assert_eq!(format_tokens_cli(2_500), "2.5K");
236 assert_eq!(format_tokens_cli(3_400_000), "3.4M");
237 assert_eq!(format_tokens_cli(1_310_000_000), "1.31B");
239 assert_eq!(format_tokens_cli(1_500_000_000_000), "1.50T");
240 }
241
242 #[test]
243 fn explicit_root_is_not_promoted_to_parent_git_root() {
244 let args = vec![
245 "build".to_string(),
246 "--root".to_string(),
247 "/home/example/travail".to_string(),
248 ];
249
250 assert_eq!(detect_project_root(&args), "/home/example/travail");
251 }
252
253 #[test]
254 fn explicit_root_expands_home_prefix() {
255 let Some(home) = dirs::home_dir() else {
256 return;
257 };
258
259 assert_eq!(
260 normalize_explicit_root("~/travail"),
261 home.join("travail").to_string_lossy().to_string()
262 );
263 }
264}