intelli_shell/utils/
process.rs

1use std::{
2    cmp::Ordering,
3    collections::BTreeMap,
4    env,
5    ffi::OsStr,
6    io::{self, Read, Write},
7    ops::Deref,
8    path::{Path, PathBuf},
9    process::{self, ExitStatus, Stdio},
10    sync::LazyLock,
11    time::Duration,
12};
13
14use color_eyre::eyre::Context;
15use ignore::WalkBuilder;
16use os_info::Info;
17use sysinfo::{Pid, System};
18use tokio::{
19    io::{AsyncBufReadExt, BufReader},
20    signal,
21};
22use wait_timeout::ChildExt;
23
24#[derive(Debug)]
25pub struct ShellInfo {
26    pub kind: ShellType,
27    pub version: Option<String>,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, strum::Display, strum::EnumString)]
31pub enum ShellType {
32    #[strum(serialize = "cmd", serialize = "cmd.exe")]
33    Cmd,
34    #[strum(serialize = "powershell", serialize = "powershell.exe")]
35    WindowsPowerShell,
36    #[strum(serialize = "pwsh", serialize = "pwsh.exe")]
37    PowerShellCore,
38    #[strum(to_string = "bash", serialize = "bash.exe")]
39    Bash,
40    #[strum(serialize = "sh")]
41    Sh,
42    #[strum(serialize = "fish")]
43    Fish,
44    #[strum(serialize = "zsh")]
45    Zsh,
46    #[strum(default, to_string = "{0}")]
47    Other(String),
48}
49
50static PARENT_SHELL_INFO: LazyLock<ShellInfo> = LazyLock::new(|| {
51    let pid = Pid::from_u32(process::id());
52
53    tracing::debug!("Retrieving info for pid {pid}");
54    let sys = System::new_all();
55
56    let parent_process = sys
57        .process(Pid::from_u32(process::id()))
58        .expect("Couldn't retrieve current process from pid")
59        .parent()
60        .and_then(|parent_pid| sys.process(parent_pid));
61
62    let default = if cfg!(target_os = "windows") {
63        ShellType::WindowsPowerShell
64    } else {
65        ShellType::Sh
66    };
67
68    let Some(parent) = parent_process else {
69        tracing::warn!("Couldn't detect shell, assuming {default}");
70        return ShellInfo {
71            kind: default,
72            version: None,
73        };
74    };
75
76    let parent_name = parent
77        .name()
78        .to_str()
79        .expect("Invalid parent shell name")
80        .trim()
81        .to_lowercase();
82
83    let kind = if parent_name == "cargo" || parent_name == "cargo.exe" {
84        tracing::warn!("Executed through cargo, assuming {default}");
85        return ShellInfo {
86            kind: default,
87            version: None,
88        };
89    } else {
90        ShellType::try_from(parent_name.as_str()).expect("infallible")
91    };
92
93    tracing::info!("Detected shell: {kind}");
94
95    let exe_path = parent
96        .exe()
97        .map(|p| p.as_os_str())
98        .filter(|p| !p.is_empty())
99        .unwrap_or_else(|| parent_name.as_ref());
100    let version = get_shell_version(&kind, exe_path).inspect(|v| tracing::info!("Detected shell version: {v}"));
101
102    ShellInfo { kind, version }
103});
104
105/// A helper function to get the version from a shell's executable path
106fn get_shell_version(shell_kind: &ShellType, shell_path: impl AsRef<OsStr>) -> Option<String> {
107    // `cmd.exe` version is tied to the OS version, so we don't query it
108    if *shell_kind == ShellType::Cmd {
109        return None;
110    }
111
112    // Most shells respond to `--version`, except PowerShell
113    let mut command = std::process::Command::new(shell_path);
114    if matches!(shell_kind, ShellType::PowerShellCore | ShellType::WindowsPowerShell) {
115        command.args([
116            "-Command",
117            "'PowerShell {0} ({1} Edition)' -f $PSVersionTable.PSVersion, $PSVersionTable.PSEdition",
118        ]);
119    } else {
120        command.arg("--version");
121    }
122
123    // Configure pipes for stdout and stderr to capture the output manually
124    let mut child = match command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() {
125        Ok(child) => child,
126        Err(err) => {
127            tracing::warn!("Failed to spawn shell process: {err}");
128            return None;
129        }
130    };
131
132    // Wait for the process to exit, with a timeout
133    match child.wait_timeout(Duration::from_millis(250)) {
134        // The command finished within the timeout period
135        Ok(Some(status)) => {
136            if status.success() {
137                let mut output = String::new();
138                // Read the output from the stdout pipe
139                if let Some(mut stdout) = child.stdout {
140                    stdout.read_to_string(&mut output).unwrap_or_default();
141                }
142                // Return just the first line of the output
143                Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
144            } else {
145                tracing::warn!("Shell version command failed with status: {}", status);
146                None
147            }
148        }
149        // The command timed out
150        Ok(None) => {
151            // Kill the child process to prevent it from running forever
152            if let Err(err) = child.kill() {
153                tracing::warn!("Failed to kill timed-out process: {err}");
154            }
155            tracing::warn!("Shell version command timed out");
156            None
157        }
158        // An error occurred while waiting
159        Err(err) => {
160            tracing::warn!("Error waiting for shell version command: {err}");
161            None
162        }
163    }
164}
165
166/// Retrieves information about the current shell, including its type and version
167pub fn get_shell_info() -> &'static ShellInfo {
168    PARENT_SHELL_INFO.deref()
169}
170
171/// Retrieves the current shell type
172pub fn get_shell_type() -> &'static ShellType {
173    &get_shell_info().kind
174}
175
176/// A helper function to get the version from an executable (e.g. git)
177pub fn get_executable_version(root_cmd: impl AsRef<OsStr>) -> Option<String> {
178    if root_cmd.as_ref().is_empty() {
179        return None;
180    }
181
182    // Most shells commands respond to `--version`
183    let mut child = std::process::Command::new(root_cmd)
184        .arg("--version")
185        .stdout(Stdio::piped())
186        .stderr(Stdio::piped())
187        .spawn()
188        .ok()?;
189
190    // Wait for the process to exit, with a timeout
191    match child.wait_timeout(Duration::from_millis(250)) {
192        Ok(Some(status)) if status.success() => {
193            let mut output = String::new();
194            if let Some(mut stdout) = child.stdout {
195                stdout.read_to_string(&mut output).unwrap_or_default();
196            }
197            Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
198        }
199        Ok(None) => {
200            if let Err(err) = child.kill() {
201                tracing::warn!("Failed to kill timed-out process: {err}");
202            }
203            None
204        }
205        _ => None,
206    }
207}
208
209static OS_INFO: LazyLock<Info> = LazyLock::new(|| {
210    let info = os_info::get();
211    tracing::info!("Detected OS: {info}");
212    info
213});
214
215/// Retrieves the operating system information
216pub fn get_os_info() -> &'static Info {
217    &OS_INFO
218}
219
220static WORING_DIR: LazyLock<String> = LazyLock::new(|| {
221    std::env::current_dir()
222        .inspect_err(|err| tracing::warn!("Couldn't retrieve current dir: {err}"))
223        .ok()
224        .and_then(|p| p.to_str().map(|s| s.to_owned()))
225        .unwrap_or_default()
226});
227
228/// Retrieves the working directory
229pub fn get_working_dir() -> &'static str {
230    WORING_DIR.deref()
231}
232
233/// Formats an env var name into its shell representation, based on the current shell
234pub fn format_env_var(var: impl AsRef<str>) -> String {
235    let var = var.as_ref();
236    match get_shell_type() {
237        ShellType::Cmd => format!("%{var}%"),
238        ShellType::WindowsPowerShell | ShellType::PowerShellCore => format!("$env:{var}"),
239        _ => format!("${var}"),
240    }
241}
242
243/// Generates a string representation of the current working directory tree, respecting .gitignore files
244pub fn generate_working_dir_tree(max_depth: usize, entry_limit: usize) -> Option<String> {
245    let root = PathBuf::from(get_working_dir());
246    if !root.is_dir() {
247        return None;
248    }
249
250    let root_canon = root.canonicalize().ok()?;
251
252    // Phase 1: Collect all entries by depth and also get total child counts for every directory
253    let mut entries_by_depth: BTreeMap<usize, Vec<ignore::DirEntry>> = BTreeMap::new();
254    let mut total_child_counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
255    let walker = WalkBuilder::new(&root_canon).max_depth(Some(max_depth + 1)).build();
256
257    for entry in walker.flatten() {
258        if entry.depth() == 0 {
259            continue;
260        }
261        if let Some(parent_path) = entry.path().parent() {
262            *total_child_counts.entry(parent_path.to_path_buf()).or_default() += 1;
263        }
264        entries_by_depth.entry(entry.depth()).or_default().push(entry);
265    }
266
267    // Phase 2: Create a limited list of entries using the breadth-first approach
268    let mut limited_entries: Vec<ignore::DirEntry> = Vec::with_capacity(entry_limit);
269    'outer: for (_depth, entries) in entries_by_depth {
270        for entry in entries {
271            if limited_entries.len() >= entry_limit {
272                break 'outer;
273            }
274            limited_entries.push(entry);
275        }
276    }
277
278    // Phase 3: Populate the display tree and add "..." where contents are truncated
279    let mut dir_children: BTreeMap<PathBuf, Vec<(String, bool)>> = BTreeMap::new();
280    for entry in limited_entries {
281        let is_dir = entry.path().is_dir();
282        if let Some(parent_path) = entry.path().parent() {
283            let file_name = entry.file_name().to_string_lossy().to_string();
284            dir_children
285                .entry(parent_path.to_path_buf())
286                .or_default()
287                .push((file_name, is_dir));
288        }
289    }
290    for (path, total_count) in total_child_counts {
291        let displayed_count = dir_children.get(&path).map_or(0, |v| v.len());
292        if displayed_count < total_count {
293            dir_children.entry(path).or_default().push(("...".to_string(), false));
294        }
295    }
296
297    // Sort the children in each directory alphabetically for consistent output
298    for children in dir_children.values_mut() {
299        children.sort_by(|a, b| {
300            // "..." is always last
301            if a.0 == "..." {
302                Ordering::Greater
303            } else if b.0 == "..." {
304                Ordering::Less
305            } else {
306                // Otherwise, sort alphabetically
307                a.0.cmp(&b.0)
308            }
309        });
310    }
311
312    // Phase 4: Build the final string
313    let mut tree_string = format!("{} (current working dir)\n", root_canon.display());
314    build_tree_from_map(&root_canon, "", &mut tree_string, &dir_children);
315    Some(tree_string)
316}
317
318/// Recursively builds the tree string from the pre-compiled map of directory children
319fn build_tree_from_map(
320    dir_path: &Path,
321    prefix: &str,
322    output: &mut String,
323    dir_children: &BTreeMap<PathBuf, Vec<(String, bool)>>,
324) {
325    let Some(entries) = dir_children.get(dir_path) else {
326        return;
327    };
328
329    let mut iter = entries.iter().peekable();
330    while let Some((name, is_dir)) = iter.next() {
331        let is_last = iter.peek().is_none();
332        let connector = if is_last { "└── " } else { "├── " };
333        let new_prefix = format!("{prefix}{}", if is_last { "    " } else { "│   " });
334
335        if *is_dir {
336            // This is a directory; let's see if we can collapse it
337            let mut path_components = vec![name.clone()];
338            let mut current_path = dir_path.join(name);
339
340            // Keep collapsing as long as the current directory has only one child, which is also a directory
341            while let Some(children) = dir_children.get(&current_path) {
342                if children.len() == 1 {
343                    let (child_name, child_is_dir) = &children[0];
344                    if *child_is_dir {
345                        path_components.push(child_name.clone());
346                        current_path.push(child_name);
347                        // Continue to the next level of nesting
348                        continue;
349                    }
350                }
351                // Stop collapsing
352                break;
353            }
354
355            // Print the combined, collapsed path.
356            let collapsed_name = path_components.join("/");
357            output.push_str(&format!("{prefix}{connector}{collapsed_name}/\n"));
358
359            // Recurse using the final path in the chain
360            build_tree_from_map(&current_path, &new_prefix, output, dir_children);
361        } else {
362            // This is a file or "...", print it normally.
363            output.push_str(&format!("{prefix}{connector}{name}\n"));
364        }
365    }
366}
367
368/// Executes a shell command, inheriting the parent's `stdout` and `stderr`
369pub async fn execute_shell_command_inherit(command: &str, include_prompt: bool) -> color_eyre::Result<ExitStatus> {
370    let mut cmd = prepare_command_execution(command, true, include_prompt)?;
371
372    // Spawn the child process to get a handle to it
373    let mut child = cmd
374        .spawn()
375        .with_context(|| format!("Failed to spawn command: `{command}`"))?;
376
377    // Race the child process against a Ctrl+C signal
378    let status = tokio::select! {
379        // Prioritize Ctrl+C handler
380        biased;
381        // User presses Ctrl+C
382        _ = signal::ctrl_c() => {
383            tracing::info!("Received Ctrl+C, terminating child process...");
384            // Send a kill signal to the child process
385            child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
386            // Wait for the process to exit and get its status
387            child.wait().await.with_context(|| "Failed to await child process after kill")?
388        }
389        // The child process completes on its own
390        status = child.wait() => {
391            status.with_context(|| format!("Child process for command `{command}` failed"))?
392        }
393    };
394
395    Ok(status)
396}
397
398/// Executes a shell command, capturing `stdout` and `stderr`.
399///
400/// While capturing, it simultaneously prints both streams to the parent's `stderr` in real-time.
401pub async fn execute_shell_command_capture(
402    command: &str,
403    include_prompt: bool,
404) -> color_eyre::Result<(ExitStatus, String, bool)> {
405    let mut cmd = prepare_command_execution(command, true, include_prompt)?;
406
407    // Configure the command to capture output streams by creating pipes
408    cmd.stdout(Stdio::piped());
409    cmd.stderr(Stdio::piped());
410
411    let mut child = cmd
412        .spawn()
413        .with_context(|| format!("Failed to spawn command: `{command}`"))?;
414
415    // Create buffered readers for the child's output streams
416    let mut stdout_reader = BufReader::new(child.stdout.take().unwrap()).lines();
417    let mut stderr_reader = BufReader::new(child.stderr.take().unwrap()).lines();
418
419    let mut output_capture = String::new();
420
421    // Flag to track if the process was terminated by our signal handler
422    let mut terminated_by_signal = false;
423
424    // Use boolean flags to track when each stream is finished
425    let mut stdout_done = false;
426    let mut stderr_done = false;
427
428    // Loop until both stdout and stderr streams have been completely read
429    while !stdout_done || !stderr_done {
430        tokio::select! {
431            // Prioritize Ctrl+C handler
432            biased;
433            // User presses Ctrl+C
434            _ = signal::ctrl_c() => {
435                tracing::info!("Received Ctrl+C, terminating child process...");
436                // Kill the child process, this will also cause the stdout/stderr streams to close
437                child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
438                // Set the flag to true since we handled the signal
439                terminated_by_signal = true;
440                // Break the loop to proceed to the final `child.wait()`
441                break;
442            },
443            // Read from stdout if it's not done yet
444            res = stdout_reader.next_line(), if !stdout_done => {
445                match res {
446                    Ok(Some(line)) => {
447                        writeln!(io::stderr(), "{line}")?;
448                        output_capture.push_str(&line);
449                        output_capture.push('\n');
450                    },
451                    _ => stdout_done = true,
452                }
453            },
454            // Read from stderr if it's not done yet
455            res = stderr_reader.next_line(), if !stderr_done => {
456                match res {
457                    Ok(Some(line)) => {
458                        writeln!(io::stderr(), "{line}")?;
459                        output_capture.push_str(&line);
460                        output_capture.push('\n');
461                    },
462                    _ => stderr_done = true,
463                }
464            },
465            // This branch is taken once both output streams are done
466            else => break,
467        }
468    }
469
470    // Wait for the process to fully exit to get its final status
471    let status = child.wait().await.wrap_err("Failed to wait for command")?;
472
473    Ok((status, output_capture, terminated_by_signal))
474}
475
476/// Builds a base `Command` object for executing a command string via the OS shell
477pub fn prepare_command_execution(
478    command: &str,
479    output_command: bool,
480    include_prompt: bool,
481) -> color_eyre::Result<tokio::process::Command> {
482    // Let the OS shell parse the command, supporting complex commands, arguments, and pipelines
483    let shell = get_shell_type();
484    let shell_arg = match shell {
485        ShellType::Cmd => "/c",
486        ShellType::WindowsPowerShell => "-Command",
487        _ => "-c",
488    };
489
490    tracing::info!("Executing command: {shell} {shell_arg} -- {command}");
491
492    // Print the command on stderr
493    if output_command {
494        let write_result = if include_prompt {
495            writeln!(
496                io::stderr(),
497                "{}{command}",
498                env::var("INTELLI_EXEC_PROMPT").as_deref().unwrap_or("> "),
499            )
500        } else {
501            writeln!(io::stderr(), "{command}")
502        };
503        // Handle broken pipe
504        if let Err(err) = write_result {
505            if err.kind() != io::ErrorKind::BrokenPipe {
506                return Err(err).wrap_err("Failed writing to stderr");
507            }
508            tracing::error!("Failed writing to stderr: Broken pipe");
509        };
510    }
511
512    // Build the base command object
513    let mut cmd = tokio::process::Command::new(shell.to_string());
514    cmd.arg(shell_arg).arg(command).kill_on_drop(true);
515    Ok(cmd)
516}