Skip to main content

lean_ctx/cli/
common.rs

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/// Strip savings footers from daemon output when the CLI client has footer suppressed.
9#[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    // Shell history files (especially zsh's metafied format) frequently contain
53    // non-UTF-8 bytes; `read_to_string` would reject the whole file. Read raw and
54    // decode lossily so a single bad byte never hides 900 lines of real history.
55    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        // Heavy users cross 1B; keep growing visibly instead of "1310.0M".
90        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 promote_to_git_root(v);
169        }
170        if let Some(v) = a.strip_prefix("--project-root=")
171            && !v.trim().is_empty()
172        {
173            return promote_to_git_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 promote_to_git_root(v);
181        }
182    }
183    let cwd = std::env::current_dir()
184        .ok()
185        .map_or_else(|| ".".to_string(), |p| p.to_string_lossy().to_string());
186    promote_to_git_root(&cwd)
187}
188
189fn promote_to_git_root(path: &str) -> String {
190    let mut p = std::path::Path::new(path);
191    loop {
192        if p.join(".git").exists() {
193            return p.to_string_lossy().to_string();
194        }
195        match p.parent() {
196            Some(parent) => p = parent,
197            None => return path.to_string(),
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::format_tokens_cli;
205
206    #[test]
207    fn format_tokens_cli_scales_through_billions() {
208        assert_eq!(format_tokens_cli(742), "742");
209        assert_eq!(format_tokens_cli(2_500), "2.5K");
210        assert_eq!(format_tokens_cli(3_400_000), "3.4M");
211        // Must read as billions once a heavy user crosses 1B, not "1310.0M".
212        assert_eq!(format_tokens_cli(1_310_000_000), "1.31B");
213        assert_eq!(format_tokens_cli(1_500_000_000_000), "1.50T");
214    }
215}