Skip to main content

lean_ctx/shell/
exec.rs

1use std::io::{self, IsTerminal, Write};
2use std::process::{Command, Stdio};
3
4use crate::core::config;
5use crate::core::slow_log;
6use crate::core::tokens::count_tokens;
7
8/// Execute a command from pre-split argv without going through `sh -c`.
9/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
10/// already correctly split by the user's shell, so re-serializing them
11/// into a string and re-parsing via `sh -c` would risk mangling complex
12/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
13pub fn exec_argv(args: &[String]) -> i32 {
14    if args.is_empty() {
15        return 127;
16    }
17
18    if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
19        return exec_direct(args);
20    }
21
22    let joined = super::platform::join_command(args);
23    let cfg = config::Config::load();
24
25    if super::compress::is_excluded_command(&joined, &cfg.excluded_commands) {
26        let code = exec_direct(args);
27        crate::core::tool_lifecycle::record_shell_command(0, 0);
28        return code;
29    }
30
31    let code = exec_direct(args);
32    crate::core::tool_lifecycle::record_shell_command(0, 0);
33    code
34}
35
36fn exec_direct(args: &[String]) -> i32 {
37    let status = Command::new(&args[0])
38        .args(&args[1..])
39        .env("LEAN_CTX_ACTIVE", "1")
40        .stdin(Stdio::inherit())
41        .stdout(Stdio::inherit())
42        .stderr(Stdio::inherit())
43        .status();
44
45    match status {
46        Ok(s) => s.code().unwrap_or(1),
47        Err(e) => {
48            tracing::error!("lean-ctx: failed to execute: {e}");
49            127
50        }
51    }
52}
53
54pub fn exec(command: &str) -> i32 {
55    let (shell, shell_flag) = super::platform::shell_and_flag();
56    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
57    let command = command.as_str();
58
59    if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
60        return exec_inherit(command, &shell, &shell_flag);
61    }
62
63    let cfg = config::Config::load();
64    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
65    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
66
67    if raw_mode
68        || (!force_compress
69            && super::compress::is_excluded_command(command, &cfg.excluded_commands))
70    {
71        return exec_inherit_tracked(command, &shell, &shell_flag);
72    }
73
74    if !force_compress {
75        if io::stdout().is_terminal() {
76            return exec_inherit_tracked(command, &shell, &shell_flag);
77        }
78        let code = exec_inherit(command, &shell, &shell_flag);
79        crate::core::tool_lifecycle::record_shell_command(0, 0);
80        return code;
81    }
82
83    exec_buffered(command, &shell, &shell_flag, &cfg)
84}
85
86fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
87    let status = Command::new(shell)
88        .arg(shell_flag)
89        .arg(command)
90        .env("LEAN_CTX_ACTIVE", "1")
91        .stdin(Stdio::inherit())
92        .stdout(Stdio::inherit())
93        .stderr(Stdio::inherit())
94        .status();
95
96    match status {
97        Ok(s) => s.code().unwrap_or(1),
98        Err(e) => {
99            tracing::error!("lean-ctx: failed to execute: {e}");
100            127
101        }
102    }
103}
104
105fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
106    let code = exec_inherit(command, shell, shell_flag);
107    crate::core::tool_lifecycle::record_shell_command(0, 0);
108    code
109}
110
111fn combine_output(stdout: &str, stderr: &str) -> String {
112    if stderr.is_empty() {
113        stdout.to_string()
114    } else if stdout.is_empty() {
115        stderr.to_string()
116    } else {
117        format!("{stdout}\n{stderr}")
118    }
119}
120
121fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
122    #[cfg(windows)]
123    super::platform::set_console_utf8();
124
125    let start = std::time::Instant::now();
126
127    let mut cmd = Command::new(shell);
128    cmd.arg(shell_flag);
129
130    #[cfg(windows)]
131    let ps_tmp_path: Option<tempfile::TempPath>;
132    #[cfg(windows)]
133    {
134        let is_powershell =
135            shell.to_lowercase().contains("powershell") || shell.to_lowercase().contains("pwsh");
136        if is_powershell {
137            let ps_script = format!(
138                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
139                command
140            );
141            let tmp = tempfile::Builder::new()
142                .prefix("lean-ctx-ps-")
143                .suffix(".ps1")
144                .tempfile()
145                .expect("failed to create temp file for PowerShell script");
146            let tmp_path = tmp.into_temp_path();
147            let _ = std::fs::write(&tmp_path, &ps_script);
148            cmd.args([
149                "-ExecutionPolicy",
150                "Bypass",
151                "-File",
152                &tmp_path.to_string_lossy(),
153            ]);
154            ps_tmp_path = Some(tmp_path);
155        } else {
156            cmd.arg(command);
157            ps_tmp_path = None;
158        }
159    }
160    #[cfg(not(windows))]
161    cmd.arg(command);
162
163    let child = cmd
164        .env("LEAN_CTX_ACTIVE", "1")
165        .stdout(Stdio::piped())
166        .stderr(Stdio::piped())
167        .spawn();
168
169    let child = match child {
170        Ok(c) => c,
171        Err(e) => {
172            tracing::error!("lean-ctx: failed to execute: {e}");
173            #[cfg(windows)]
174            if let Some(ref tmp) = ps_tmp_path {
175                let _ = std::fs::remove_file(tmp);
176            }
177            return 127;
178        }
179    };
180
181    let output = match child.wait_with_output() {
182        Ok(o) => o,
183        Err(e) => {
184            tracing::error!("lean-ctx: failed to wait: {e}");
185            #[cfg(windows)]
186            if let Some(ref tmp) = ps_tmp_path {
187                let _ = std::fs::remove_file(tmp);
188            }
189            return 127;
190        }
191    };
192
193    let duration_ms = start.elapsed().as_millis();
194    let exit_code = output.status.code().unwrap_or(1);
195    let stdout = super::platform::decode_output(&output.stdout);
196    let stderr = super::platform::decode_output(&output.stderr);
197
198    let full_output = combine_output(&stdout, &stderr);
199    let input_tokens = count_tokens(&full_output);
200
201    let (compressed, output_tokens) =
202        super::compress::compress_and_measure(command, &stdout, &stderr);
203
204    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
205
206    if !compressed.is_empty() {
207        let _ = io::stdout().write_all(compressed.as_bytes());
208        if !compressed.ends_with('\n') {
209            let _ = io::stdout().write_all(b"\n");
210        }
211    }
212    let should_tee = match cfg.tee_mode {
213        config::TeeMode::Always => !full_output.trim().is_empty(),
214        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
215        config::TeeMode::Never => false,
216    };
217    if should_tee {
218        if let Some(path) = super::redact::save_tee(command, &full_output) {
219            eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
220        }
221    }
222
223    let threshold = cfg.slow_command_threshold_ms;
224    if threshold > 0 && duration_ms >= threshold as u128 {
225        slow_log::record(command, duration_ms, exit_code);
226    }
227
228    #[cfg(windows)]
229    if let Some(ref tmp) = ps_tmp_path {
230        let _ = std::fs::remove_file(tmp);
231    }
232
233    exit_code
234}
235
236#[cfg(test)]
237mod exec_tests {
238    #[test]
239    fn exec_direct_runs_true() {
240        let code = super::exec_direct(&["true".to_string()]);
241        assert_eq!(code, 0);
242    }
243
244    #[test]
245    fn exec_direct_runs_false() {
246        let code = super::exec_direct(&["false".to_string()]);
247        assert_ne!(code, 0);
248    }
249
250    #[test]
251    fn exec_direct_preserves_args_with_special_chars() {
252        let code = super::exec_direct(&[
253            "echo".to_string(),
254            "hello world".to_string(),
255            "it's here".to_string(),
256            "a \"quoted\" thing".to_string(),
257        ]);
258        assert_eq!(code, 0);
259    }
260
261    #[test]
262    fn exec_direct_nonexistent_returns_127() {
263        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
264        assert_eq!(code, 127);
265    }
266
267    #[test]
268    fn exec_argv_empty_returns_127() {
269        let code = super::exec_argv(&[]);
270        assert_eq!(code, 127);
271    }
272
273    #[test]
274    fn exec_argv_runs_simple_command() {
275        let code = super::exec_argv(&["true".to_string()]);
276        assert_eq!(code, 0);
277    }
278
279    #[test]
280    fn exec_argv_passes_through_when_disabled() {
281        std::env::set_var("LEAN_CTX_DISABLED", "1");
282        let code = super::exec_argv(&["true".to_string()]);
283        std::env::remove_var("LEAN_CTX_DISABLED");
284        assert_eq!(code, 0);
285    }
286}