1use std::{
2 borrow::Cow,
3 cmp::Ordering,
4 collections::BTreeMap,
5 env,
6 ffi::OsStr,
7 io::{self, Read, Write},
8 ops::Deref,
9 path::{Path, PathBuf},
10 process::{self, ExitStatus, Stdio},
11 sync::LazyLock,
12 time::Duration,
13};
14
15use color_eyre::eyre::Context;
16use ignore::WalkBuilder;
17use os_info::Info;
18use sysinfo::{Pid, System};
19use tokio::io::{AsyncBufReadExt, BufReader};
20use tokio_util::sync::CancellationToken;
21use wait_timeout::ChildExt;
22
23#[derive(Debug)]
24pub struct ShellInfo {
25 pub kind: ShellType,
26 pub version: Option<String>,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq, strum::Display, strum::EnumString)]
30pub enum ShellType {
31 #[strum(serialize = "cmd", serialize = "cmd.exe")]
32 Cmd,
33 #[strum(serialize = "powershell", serialize = "powershell.exe")]
34 WindowsPowerShell,
35 #[strum(to_string = "pwsh", serialize = "pwsh.exe")]
36 PowerShellCore,
37 #[strum(to_string = "bash", serialize = "bash.exe")]
38 Bash,
39 #[strum(serialize = "sh")]
40 Sh,
41 #[strum(serialize = "fish")]
42 Fish,
43 #[strum(serialize = "zsh")]
44 Zsh,
45 #[strum(to_string = "nu", serialize = "nu.exe")]
46 Nushell,
47 #[strum(default, to_string = "{0}")]
48 Other(String),
49}
50
51static PARENT_SHELL_INFO: LazyLock<ShellInfo> = LazyLock::new(|| {
52 let default = if cfg!(target_os = "windows") {
54 ShellType::WindowsPowerShell
55 } else {
56 ShellType::Sh
57 };
58
59 if cfg!(test) {
61 tracing::info!("Using default shell for tests: {default}");
62 return ShellInfo {
63 kind: default,
64 version: None,
65 };
66 }
67
68 let pid = Pid::from_u32(process::id());
70
71 tracing::debug!("Retrieving info for pid {pid}");
72 let sys = System::new_all();
73
74 let parent_process = sys
75 .process(Pid::from_u32(process::id()))
76 .expect("Couldn't retrieve current process from pid")
77 .parent()
78 .and_then(|parent_pid| sys.process(parent_pid));
79
80 let Some(parent) = parent_process else {
81 tracing::warn!("Couldn't detect shell, assuming {default}");
82 return ShellInfo {
83 kind: default,
84 version: None,
85 };
86 };
87
88 let parent_name = parent
89 .name()
90 .to_str()
91 .expect("Invalid parent shell name")
92 .trim()
93 .to_lowercase();
94
95 let kind = if parent_name == "cargo" || parent_name == "cargo.exe" {
96 tracing::warn!("Executed through cargo, assuming {default}");
97 return ShellInfo {
98 kind: default,
99 version: None,
100 };
101 } else {
102 ShellType::try_from(parent_name.as_str()).expect("infallible")
103 };
104
105 tracing::info!("Detected shell: {kind}");
106
107 let exe_path = parent
108 .exe()
109 .map(|p| p.as_os_str())
110 .filter(|p| !p.is_empty())
111 .unwrap_or_else(|| parent_name.as_ref());
112 let version = get_shell_version(&kind, exe_path).inspect(|v| tracing::info!("Detected shell version: {v}"));
113
114 ShellInfo { kind, version }
115});
116
117fn get_shell_version(shell_kind: &ShellType, shell_path: impl AsRef<OsStr>) -> Option<String> {
119 if *shell_kind == ShellType::Cmd {
121 return None;
122 }
123
124 let mut command = std::process::Command::new(shell_path);
126 if matches!(shell_kind, ShellType::PowerShellCore | ShellType::WindowsPowerShell) {
127 command.args([
128 "-NoProfile",
129 "-Command",
130 "'PowerShell {0} ({1} Edition)' -f $PSVersionTable.PSVersion, $PSVersionTable.PSEdition",
131 ]);
132 } else {
133 command.arg("--version");
134 }
135
136 let mut child = match command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() {
138 Ok(child) => child,
139 Err(err) => {
140 tracing::warn!("Failed to spawn shell process: {err}");
141 return None;
142 }
143 };
144
145 match child.wait_timeout(Duration::from_millis(250)) {
147 Ok(Some(status)) => {
149 if status.success() {
150 let mut output = String::new();
151 if let Some(mut stdout) = child.stdout {
153 stdout.read_to_string(&mut output).unwrap_or_default();
154 }
155 Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
157 } else {
158 tracing::warn!("Shell version command failed with status: {}", status);
159 None
160 }
161 }
162 Ok(None) => {
164 if let Err(err) = child.kill() {
166 tracing::warn!("Failed to kill timed-out process: {err}");
167 }
168 tracing::warn!("Shell version command timed out");
169 None
170 }
171 Err(err) => {
173 tracing::warn!("Error waiting for shell version command: {err}");
174 None
175 }
176 }
177}
178
179pub fn get_shell_info() -> &'static ShellInfo {
181 PARENT_SHELL_INFO.deref()
182}
183
184pub fn get_shell_type() -> &'static ShellType {
186 &get_shell_info().kind
187}
188
189pub fn get_executable_version(root_cmd: impl AsRef<OsStr>) -> Option<String> {
191 if root_cmd.as_ref().is_empty() {
192 return None;
193 }
194
195 let mut child = std::process::Command::new(root_cmd)
197 .arg("--version")
198 .stdout(Stdio::piped())
199 .stderr(Stdio::piped())
200 .spawn()
201 .ok()?;
202
203 match child.wait_timeout(Duration::from_millis(250)) {
205 Ok(Some(status)) if status.success() => {
206 let mut output = String::new();
207 if let Some(mut stdout) = child.stdout {
208 stdout.read_to_string(&mut output).unwrap_or_default();
209 }
210 Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
211 }
212 Ok(None) => {
213 if let Err(err) = child.kill() {
214 tracing::warn!("Failed to kill timed-out process: {err}");
215 }
216 None
217 }
218 _ => None,
219 }
220}
221
222static OS_INFO: LazyLock<Info> = LazyLock::new(|| {
223 let info = os_info::get();
224 tracing::info!("Detected OS: {info}");
225 info
226});
227
228pub fn get_os_info() -> &'static Info {
230 &OS_INFO
231}
232
233static WORING_DIR: LazyLock<String> = LazyLock::new(|| {
234 std::env::current_dir()
235 .inspect_err(|err| tracing::warn!("Couldn't retrieve current dir: {err}"))
236 .ok()
237 .and_then(|p| p.to_str().map(|s| s.to_owned()))
238 .unwrap_or_default()
239});
240
241pub fn get_working_dir() -> &'static str {
243 WORING_DIR.deref()
244}
245
246pub fn format_env_var(var: impl AsRef<str>) -> String {
248 let var = var.as_ref();
249 match get_shell_type() {
250 ShellType::Cmd => format!("%{var}%"),
251 ShellType::WindowsPowerShell | ShellType::PowerShellCore => format!("$env:{var}"),
252 ShellType::Nushell => format!("$env.{var}"),
253 _ => format!("${var}"),
254 }
255}
256
257pub fn generate_working_dir_tree(max_depth: usize, entry_limit: usize) -> Option<String> {
259 let root = PathBuf::from(get_working_dir());
260 if !root.is_dir() {
261 return None;
262 }
263
264 let root_canon = root.canonicalize().ok()?;
265
266 let mut entries_by_depth: BTreeMap<usize, Vec<ignore::DirEntry>> = BTreeMap::new();
268 let mut total_child_counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
269 let walker = WalkBuilder::new(&root_canon).max_depth(Some(max_depth + 1)).build();
270
271 for entry in walker.flatten() {
272 if entry.depth() == 0 {
273 continue;
274 }
275 if let Some(parent_path) = entry.path().parent() {
276 *total_child_counts.entry(parent_path.to_path_buf()).or_default() += 1;
277 }
278 entries_by_depth.entry(entry.depth()).or_default().push(entry);
279 }
280
281 let mut limited_entries: Vec<ignore::DirEntry> = Vec::with_capacity(entry_limit);
283 'outer: for (_depth, entries) in entries_by_depth {
284 for entry in entries {
285 if limited_entries.len() >= entry_limit {
286 break 'outer;
287 }
288 limited_entries.push(entry);
289 }
290 }
291
292 let mut dir_children: BTreeMap<PathBuf, Vec<(String, bool)>> = BTreeMap::new();
294 for entry in limited_entries {
295 let is_dir = entry.path().is_dir();
296 if let Some(parent_path) = entry.path().parent() {
297 let file_name = entry.file_name().to_string_lossy().to_string();
298 dir_children
299 .entry(parent_path.to_path_buf())
300 .or_default()
301 .push((file_name, is_dir));
302 }
303 }
304 for (path, total_count) in total_child_counts {
305 let displayed_count = dir_children.get(&path).map_or(0, |v| v.len());
306 if displayed_count < total_count {
307 dir_children.entry(path).or_default().push(("...".to_string(), false));
308 }
309 }
310
311 for children in dir_children.values_mut() {
313 children.sort_by(|a, b| {
314 if a.0 == "..." {
316 Ordering::Greater
317 } else if b.0 == "..." {
318 Ordering::Less
319 } else {
320 a.0.cmp(&b.0)
322 }
323 });
324 }
325
326 let mut tree_string = format!("{} (current working dir)\n", root_canon.display());
328 build_tree_from_map(&root_canon, "", &mut tree_string, &dir_children);
329 Some(tree_string)
330}
331
332fn build_tree_from_map(
334 dir_path: &Path,
335 prefix: &str,
336 output: &mut String,
337 dir_children: &BTreeMap<PathBuf, Vec<(String, bool)>>,
338) {
339 let Some(entries) = dir_children.get(dir_path) else {
340 return;
341 };
342
343 let mut iter = entries.iter().peekable();
344 while let Some((name, is_dir)) = iter.next() {
345 let is_last = iter.peek().is_none();
346 let connector = if is_last { "└── " } else { "├── " };
347 let new_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
348
349 if *is_dir {
350 let mut path_components = vec![name.clone()];
352 let mut current_path = dir_path.join(name);
353
354 while let Some(children) = dir_children.get(¤t_path) {
356 if children.len() == 1 {
357 let (child_name, child_is_dir) = &children[0];
358 if *child_is_dir {
359 path_components.push(child_name.clone());
360 current_path.push(child_name);
361 continue;
363 }
364 }
365 break;
367 }
368
369 let collapsed_name = path_components.join("/");
371 output.push_str(&format!("{prefix}{connector}{collapsed_name}/\n"));
372
373 build_tree_from_map(¤t_path, &new_prefix, output, dir_children);
375 } else {
376 output.push_str(&format!("{prefix}{connector}{name}\n"));
378 }
379 }
380}
381
382pub fn decode_output(bytes: &[u8]) -> Cow<'_, str> {
384 if cfg!(windows) {
386 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
388 return String::from_utf8_lossy(&bytes[3..]);
390 }
391
392 if bytes.contains(&0) {
394 let (cow, _encoding_used, _had_errors) = encoding_rs::UTF_16LE.decode(bytes);
395 return cow;
396 }
397 }
398
399 String::from_utf8_lossy(bytes)
401}
402
403pub async fn execute_shell_command_inherit(
405 command: &str,
406 include_prompt: bool,
407 cancellation_token: CancellationToken,
408) -> color_eyre::Result<ExitStatus> {
409 let mut cmd = prepare_command_execution(command, true, include_prompt)?;
410
411 let mut child = cmd
413 .spawn()
414 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
415
416 let status = tokio::select! {
418 biased;
420 _ = cancellation_token.cancelled() => {
422 tracing::info!("Received cancellation signal, terminating child process...");
423 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
425 child.wait().await.with_context(|| "Failed to await child process after kill")?
427 }
428 status = child.wait() => {
430 status.with_context(|| format!("Child process for command `{command}` failed"))?
431 }
432 };
433
434 Ok(status)
435}
436
437pub async fn execute_shell_command_capture(
441 command: &str,
442 include_prompt: bool,
443 cancellation_token: CancellationToken,
444) -> color_eyre::Result<(ExitStatus, String, bool)> {
445 let mut cmd = prepare_command_execution(command, true, include_prompt)?;
446
447 cmd.stdout(Stdio::piped());
449 cmd.stderr(Stdio::piped());
450
451 let mut child = cmd
452 .spawn()
453 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
454
455 let mut stdout_reader = BufReader::new(child.stdout.take().unwrap()).lines();
457 let mut stderr_reader = BufReader::new(child.stderr.take().unwrap()).lines();
458
459 let mut output_capture = String::new();
460
461 let mut terminated_by_token = false;
463
464 let mut stdout_done = false;
466 let mut stderr_done = false;
467
468 while !stdout_done || !stderr_done {
470 tokio::select! {
471 biased;
473 _ = cancellation_token.cancelled() => {
475 tracing::info!("Received cancellation signal, terminating child process...");
476 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
478 terminated_by_token = true;
480 break;
482 },
483 res = stdout_reader.next_line(), if !stdout_done => {
485 match res {
486 Ok(Some(line)) => {
487 writeln!(io::stderr(), "{line}")?;
488 output_capture.push_str(&line);
489 output_capture.push('\n');
490 },
491 _ => stdout_done = true,
492 }
493 },
494 res = stderr_reader.next_line(), if !stderr_done => {
496 match res {
497 Ok(Some(line)) => {
498 writeln!(io::stderr(), "{line}")?;
499 output_capture.push_str(&line);
500 output_capture.push('\n');
501 },
502 _ => stderr_done = true,
503 }
504 },
505 else => break,
507 }
508 }
509
510 let status = child.wait().await.wrap_err("Failed to wait for command")?;
512
513 Ok((status, output_capture, terminated_by_token))
514}
515
516pub fn prepare_command_execution(
518 command: &str,
519 output_command: bool,
520 include_prompt: bool,
521) -> color_eyre::Result<tokio::process::Command> {
522 let shell = get_shell_type();
524 let shell_arg = match shell {
525 ShellType::Cmd => "/c",
526 ShellType::WindowsPowerShell => "-Command",
527 _ => "-c",
528 };
529
530 tracing::info!("Executing command: {shell} {shell_arg} -- {command}");
531
532 if output_command {
534 let write_result = if include_prompt {
535 writeln!(
536 io::stderr(),
537 "{}{command}",
538 env::var("INTELLI_EXEC_PROMPT").as_deref().unwrap_or("> "),
539 )
540 } else {
541 writeln!(io::stderr(), "{command}")
542 };
543 if let Err(err) = write_result {
545 if err.kind() != io::ErrorKind::BrokenPipe {
546 return Err(err).wrap_err("Failed writing to stderr");
547 }
548 tracing::error!("Failed writing to stderr: Broken pipe");
549 };
550 }
551
552 let mut cmd = tokio::process::Command::new(shell.to_string());
554 cmd.arg(shell_arg).arg(command).kill_on_drop(true);
555 Ok(cmd)
556}