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