intelli-shell 3.4.0

Like IntelliSense, but for shells
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
use std::{
    borrow::Cow,
    cmp::Ordering,
    collections::BTreeMap,
    env,
    ffi::OsStr,
    io::{self, Read, Write},
    ops::Deref,
    path::{Path, PathBuf},
    process::{self, ExitStatus, Stdio},
    sync::LazyLock,
    time::Duration,
};

use color_eyre::eyre::Context;
use ignore::WalkBuilder;
use os_info::Info;
use sysinfo::{Pid, System};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio_util::sync::CancellationToken;
use wait_timeout::ChildExt;

#[derive(Debug)]
pub struct ShellInfo {
    pub kind: ShellType,
    pub version: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, strum::Display, strum::EnumString)]
pub enum ShellType {
    #[strum(serialize = "cmd", serialize = "cmd.exe")]
    Cmd,
    #[strum(serialize = "powershell", serialize = "powershell.exe")]
    WindowsPowerShell,
    #[strum(to_string = "pwsh", serialize = "pwsh.exe")]
    PowerShellCore,
    #[strum(to_string = "bash", serialize = "bash.exe")]
    Bash,
    #[strum(serialize = "sh")]
    Sh,
    #[strum(serialize = "fish")]
    Fish,
    #[strum(serialize = "zsh")]
    Zsh,
    #[strum(to_string = "nu", serialize = "nu.exe")]
    Nushell,
    #[strum(default, to_string = "{0}")]
    Other(String),
}

static PARENT_SHELL_INFO: LazyLock<ShellInfo> = LazyLock::new(|| {
    // Default to `sh` on Unix and `powershell` on Windows if detection fails
    let default = if cfg!(target_os = "windows") {
        ShellType::WindowsPowerShell
    } else {
        ShellType::Sh
    };

    // In test mode, always return the default shell to avoid complications
    if cfg!(test) {
        tracing::info!("Using default shell for tests: {default}");
        return ShellInfo {
            kind: default,
            version: None,
        };
    }

    // Otherwise, try to detect the parent shell process
    let pid = Pid::from_u32(process::id());

    tracing::debug!("Retrieving info for pid {pid}");
    let sys = System::new_all();

    let parent_process = sys
        .process(Pid::from_u32(process::id()))
        .expect("Couldn't retrieve current process from pid")
        .parent()
        .and_then(|parent_pid| sys.process(parent_pid));

    let Some(parent) = parent_process else {
        tracing::warn!("Couldn't detect shell, assuming {default}");
        return ShellInfo {
            kind: default,
            version: None,
        };
    };

    let parent_name = parent
        .name()
        .to_str()
        .expect("Invalid parent shell name")
        .trim()
        .to_lowercase();

    let kind = if parent_name == "cargo" || parent_name == "cargo.exe" {
        tracing::warn!("Executed through cargo, assuming {default}");
        return ShellInfo {
            kind: default,
            version: None,
        };
    } else {
        ShellType::try_from(parent_name.as_str()).expect("infallible")
    };

    tracing::info!("Detected shell: {kind}");

    let exe_path = parent
        .exe()
        .map(|p| p.as_os_str())
        .filter(|p| !p.is_empty())
        .unwrap_or_else(|| parent_name.as_ref());
    let version = get_shell_version(&kind, exe_path).inspect(|v| tracing::info!("Detected shell version: {v}"));

    ShellInfo { kind, version }
});

/// A helper function to get the version from a shell's executable path
fn get_shell_version(shell_kind: &ShellType, shell_path: impl AsRef<OsStr>) -> Option<String> {
    // `cmd.exe` version is tied to the OS version, so we don't query it
    if *shell_kind == ShellType::Cmd {
        return None;
    }

    // Most shells respond to `--version`, except PowerShell
    let mut command = std::process::Command::new(shell_path);
    if matches!(shell_kind, ShellType::PowerShellCore | ShellType::WindowsPowerShell) {
        command.args([
            "-NoProfile",
            "-Command",
            "'PowerShell {0} ({1} Edition)' -f $PSVersionTable.PSVersion, $PSVersionTable.PSEdition",
        ]);
    } else {
        command.arg("--version");
    }

    // Configure pipes for stdout and stderr to capture the output manually
    let mut child = match command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() {
        Ok(child) => child,
        Err(err) => {
            tracing::warn!("Failed to spawn shell process: {err}");
            return None;
        }
    };

    // Wait for the process to exit, with a timeout
    match child.wait_timeout(Duration::from_millis(250)) {
        // The command finished within the timeout period
        Ok(Some(status)) => {
            if status.success() {
                let mut output = String::new();
                // Read the output from the stdout pipe
                if let Some(mut stdout) = child.stdout {
                    stdout.read_to_string(&mut output).unwrap_or_default();
                }
                // Return just the first line of the output
                Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
            } else {
                tracing::warn!("Shell version command failed with status: {}", status);
                None
            }
        }
        // The command timed out
        Ok(None) => {
            // Kill the child process to prevent it from running forever
            if let Err(err) = child.kill() {
                tracing::warn!("Failed to kill timed-out process: {err}");
            }
            tracing::warn!("Shell version command timed out");
            None
        }
        // An error occurred while waiting
        Err(err) => {
            tracing::warn!("Error waiting for shell version command: {err}");
            None
        }
    }
}

/// Retrieves information about the current shell, including its type and version
pub fn get_shell_info() -> &'static ShellInfo {
    PARENT_SHELL_INFO.deref()
}

/// Retrieves the current shell type
pub fn get_shell_type() -> &'static ShellType {
    &get_shell_info().kind
}

/// A helper function to get the version from an executable (e.g. git)
pub fn get_executable_version(root_cmd: impl AsRef<OsStr>) -> Option<String> {
    if root_cmd.as_ref().is_empty() {
        return None;
    }

    // Most shells commands respond to `--version`
    let mut child = std::process::Command::new(root_cmd)
        .arg("--version")
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .ok()?;

    // Wait for the process to exit, with a timeout
    match child.wait_timeout(Duration::from_millis(250)) {
        Ok(Some(status)) if status.success() => {
            let mut output = String::new();
            if let Some(mut stdout) = child.stdout {
                stdout.read_to_string(&mut output).unwrap_or_default();
            }
            Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
        }
        Ok(None) => {
            if let Err(err) = child.kill() {
                tracing::warn!("Failed to kill timed-out process: {err}");
            }
            None
        }
        _ => None,
    }
}

static OS_INFO: LazyLock<Info> = LazyLock::new(|| {
    let info = os_info::get();
    tracing::info!("Detected OS: {info}");
    info
});

/// Retrieves the operating system information
pub fn get_os_info() -> &'static Info {
    &OS_INFO
}

static WORING_DIR: LazyLock<String> = LazyLock::new(|| {
    std::env::current_dir()
        .inspect_err(|err| tracing::warn!("Couldn't retrieve current dir: {err}"))
        .ok()
        .and_then(|p| p.to_str().map(|s| s.to_owned()))
        .unwrap_or_default()
});

/// Retrieves the working directory
pub fn get_working_dir() -> &'static str {
    WORING_DIR.deref()
}

/// Formats an env var name into its shell representation, based on the current shell
pub fn format_env_var(var: impl AsRef<str>) -> String {
    let var = var.as_ref();
    match get_shell_type() {
        ShellType::Cmd => format!("%{var}%"),
        ShellType::WindowsPowerShell | ShellType::PowerShellCore => format!("$env:{var}"),
        ShellType::Nushell => format!("$env.{var}"),
        _ => format!("${var}"),
    }
}

/// Generates a string representation of the current working directory tree, respecting .gitignore files
pub fn generate_working_dir_tree(max_depth: usize, entry_limit: usize) -> Option<String> {
    let root = PathBuf::from(get_working_dir());
    if !root.is_dir() {
        return None;
    }

    let root_canon = root.canonicalize().ok()?;

    // Phase 1: Collect all entries by depth and also get total child counts for every directory
    let mut entries_by_depth: BTreeMap<usize, Vec<ignore::DirEntry>> = BTreeMap::new();
    let mut total_child_counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
    let walker = WalkBuilder::new(&root_canon).max_depth(Some(max_depth + 1)).build();

    for entry in walker.flatten() {
        if entry.depth() == 0 {
            continue;
        }
        if let Some(parent_path) = entry.path().parent() {
            *total_child_counts.entry(parent_path.to_path_buf()).or_default() += 1;
        }
        entries_by_depth.entry(entry.depth()).or_default().push(entry);
    }

    // Phase 2: Create a limited list of entries using the breadth-first approach
    let mut limited_entries: Vec<ignore::DirEntry> = Vec::with_capacity(entry_limit);
    'outer: for (_depth, entries) in entries_by_depth {
        for entry in entries {
            if limited_entries.len() >= entry_limit {
                break 'outer;
            }
            limited_entries.push(entry);
        }
    }

    // Phase 3: Populate the display tree and add "..." where contents are truncated
    let mut dir_children: BTreeMap<PathBuf, Vec<(String, bool)>> = BTreeMap::new();
    for entry in limited_entries {
        let is_dir = entry.path().is_dir();
        if let Some(parent_path) = entry.path().parent() {
            let file_name = entry.file_name().to_string_lossy().to_string();
            dir_children
                .entry(parent_path.to_path_buf())
                .or_default()
                .push((file_name, is_dir));
        }
    }
    for (path, total_count) in total_child_counts {
        let displayed_count = dir_children.get(&path).map_or(0, |v| v.len());
        if displayed_count < total_count {
            dir_children.entry(path).or_default().push(("...".to_string(), false));
        }
    }

    // Sort the children in each directory alphabetically for consistent output
    for children in dir_children.values_mut() {
        children.sort_by(|a, b| {
            // "..." is always last
            if a.0 == "..." {
                Ordering::Greater
            } else if b.0 == "..." {
                Ordering::Less
            } else {
                // Otherwise, sort alphabetically
                a.0.cmp(&b.0)
            }
        });
    }

    // Phase 4: Build the final string
    let mut tree_string = format!("{} (current working dir)\n", root_canon.display());
    build_tree_from_map(&root_canon, "", &mut tree_string, &dir_children);
    Some(tree_string)
}

/// Recursively builds the tree string from the pre-compiled map of directory children
fn build_tree_from_map(
    dir_path: &Path,
    prefix: &str,
    output: &mut String,
    dir_children: &BTreeMap<PathBuf, Vec<(String, bool)>>,
) {
    let Some(entries) = dir_children.get(dir_path) else {
        return;
    };

    let mut iter = entries.iter().peekable();
    while let Some((name, is_dir)) = iter.next() {
        let is_last = iter.peek().is_none();
        let connector = if is_last { "└── " } else { "├── " };
        let new_prefix = format!("{prefix}{}", if is_last { "    " } else { "│   " });

        if *is_dir {
            // This is a directory; let's see if we can collapse it
            let mut path_components = vec![name.clone()];
            let mut current_path = dir_path.join(name);

            // Keep collapsing as long as the current directory has only one child, which is also a directory
            while let Some(children) = dir_children.get(&current_path) {
                if children.len() == 1 {
                    let (child_name, child_is_dir) = &children[0];
                    if *child_is_dir {
                        path_components.push(child_name.clone());
                        current_path.push(child_name);
                        // Continue to the next level of nesting
                        continue;
                    }
                }
                // Stop collapsing
                break;
            }

            // Print the combined, collapsed path.
            let collapsed_name = path_components.join("/");
            output.push_str(&format!("{prefix}{connector}{collapsed_name}/\n"));

            // Recurse using the final path in the chain
            build_tree_from_map(&current_path, &new_prefix, output, dir_children);
        } else {
            // This is a file or "...", print it normally.
            output.push_str(&format!("{prefix}{connector}{name}\n"));
        }
    }
}

/// Decodes the output of a shell command based on the OS and some heuristics
pub fn decode_output(bytes: &[u8]) -> Cow<'_, str> {
    // On Windows, PowerShell is known to have inconsistent output encoding
    if cfg!(windows) {
        // A UTF-8 BOM is a strong signal that the content is UTF-8
        if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
            // It has a UTF-8 BOM, so treat as UTF-8 (after skipping the BOM)
            return String::from_utf8_lossy(&bytes[3..]);
        }

        // If the byte stream contains NUL bytes, it is almost certainly UTF-16LE
        if bytes.contains(&0) {
            let (cow, _encoding_used, _had_errors) = encoding_rs::UTF_16LE.decode(bytes);
            return cow;
        }
    }

    // For all other cases (Linux, macOS, non-PowerShell on Windows, or PowerShell output without NUL bytes)
    String::from_utf8_lossy(bytes)
}

/// Executes a shell command, inheriting the parent's `stdout` and `stderr`
pub async fn execute_shell_command_inherit(
    command: &str,
    include_prompt: bool,
    cancellation_token: CancellationToken,
) -> color_eyre::Result<ExitStatus> {
    let mut cmd = prepare_command_execution(command, true, include_prompt)?;

    // Spawn the child process to get a handle to it
    let mut child = cmd
        .spawn()
        .with_context(|| format!("Failed to spawn command: `{command}`"))?;

    // Race the child process against the cancellation token
    let status = tokio::select! {
        // Prioritize cancellation token
        biased;
        // Task is cancelled
        _ = cancellation_token.cancelled() => {
            tracing::info!("Received cancellation signal, terminating child process...");
            // Send a kill signal to the child process
            child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
            // Wait for the process to exit and get its status
            child.wait().await.with_context(|| "Failed to await child process after kill")?
        }
        // The child process completes on its own
        status = child.wait() => {
            status.with_context(|| format!("Child process for command `{command}` failed"))?
        }
    };

    Ok(status)
}

/// Executes a shell command, capturing `stdout` and `stderr`.
///
/// While capturing, it simultaneously prints both streams to the parent's `stderr` in real-time.
pub async fn execute_shell_command_capture(
    command: &str,
    include_prompt: bool,
    cancellation_token: CancellationToken,
) -> color_eyre::Result<(ExitStatus, String, bool)> {
    let mut cmd = prepare_command_execution(command, true, include_prompt)?;

    // Configure the command to capture output streams by creating pipes
    cmd.stdout(Stdio::piped());
    cmd.stderr(Stdio::piped());

    let mut child = cmd
        .spawn()
        .with_context(|| format!("Failed to spawn command: `{command}`"))?;

    // Create buffered readers for the child's output streams
    let mut stdout_reader = BufReader::new(child.stdout.take().unwrap()).lines();
    let mut stderr_reader = BufReader::new(child.stderr.take().unwrap()).lines();

    let mut output_capture = String::new();

    // Flag to track if the process was terminated by the cancellation token
    let mut terminated_by_token = false;

    // Use boolean flags to track when each stream is finished
    let mut stdout_done = false;
    let mut stderr_done = false;

    // Loop until both stdout and stderr streams have been completely read
    while !stdout_done || !stderr_done {
        tokio::select! {
            // Prioritize cancellation token
            biased;
            // Task is cancelled
            _ = cancellation_token.cancelled() => {
                tracing::info!("Received cancellation signal, terminating child process...");
                // Kill the child process, this will also cause the stdout/stderr streams to close
                child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
                // Set the flag to true since we handled the signal
                terminated_by_token = true;
                // Break the loop to proceed to the final `child.wait()`
                break;
            },
            // Read from stdout if it's not done yet
            res = stdout_reader.next_line(), if !stdout_done => {
                match res {
                    Ok(Some(line)) => {
                        writeln!(io::stderr(), "{line}")?;
                        output_capture.push_str(&line);
                        output_capture.push('\n');
                    },
                    _ => stdout_done = true,
                }
            },
            // Read from stderr if it's not done yet
            res = stderr_reader.next_line(), if !stderr_done => {
                match res {
                    Ok(Some(line)) => {
                        writeln!(io::stderr(), "{line}")?;
                        output_capture.push_str(&line);
                        output_capture.push('\n');
                    },
                    _ => stderr_done = true,
                }
            },
            // This branch is taken once both output streams are done
            else => break,
        }
    }

    // Wait for the process to fully exit to get its final status
    let status = child.wait().await.wrap_err("Failed to wait for command")?;

    Ok((status, output_capture, terminated_by_token))
}

/// Builds a base `Command` object for executing a command string via the OS shell
pub fn prepare_command_execution(
    command: &str,
    output_command: bool,
    include_prompt: bool,
) -> color_eyre::Result<tokio::process::Command> {
    // Let the OS shell parse the command, supporting complex commands, arguments, and pipelines
    let shell = get_shell_type();
    let shell_arg = match shell {
        ShellType::Cmd => "/c",
        ShellType::WindowsPowerShell => "-Command",
        _ => "-c",
    };

    tracing::info!("Executing command: {shell} {shell_arg} -- {command}");

    // Print the command on stderr
    if output_command {
        let write_result = if include_prompt {
            writeln!(
                io::stderr(),
                "{}{command}",
                env::var("INTELLI_EXEC_PROMPT").as_deref().unwrap_or("> "),
            )
        } else {
            writeln!(io::stderr(), "{command}")
        };
        // Handle broken pipe
        if let Err(err) = write_result {
            if err.kind() != io::ErrorKind::BrokenPipe {
                return Err(err).wrap_err("Failed writing to stderr");
            }
            tracing::error!("Failed writing to stderr: Broken pipe");
        };
    }

    // Build the base command object
    let mut cmd = tokio::process::Command::new(shell.to_string());
    cmd.arg(shell_arg).arg(command).kill_on_drop(true);
    Ok(cmd)
}