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
105fn get_shell_version(shell_kind: &ShellType, shell_path: impl AsRef<OsStr>) -> Option<String> {
107 if *shell_kind == ShellType::Cmd {
109 return None;
110 }
111
112 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 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 match child.wait_timeout(Duration::from_millis(250)) {
134 Ok(Some(status)) => {
136 if status.success() {
137 let mut output = String::new();
138 if let Some(mut stdout) = child.stdout {
140 stdout.read_to_string(&mut output).unwrap_or_default();
141 }
142 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 Ok(None) => {
151 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 Err(err) => {
160 tracing::warn!("Error waiting for shell version command: {err}");
161 None
162 }
163 }
164}
165
166pub fn get_shell_info() -> &'static ShellInfo {
168 PARENT_SHELL_INFO.deref()
169}
170
171pub fn get_shell_type() -> &'static ShellType {
173 &get_shell_info().kind
174}
175
176pub 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 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 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
215pub 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
228pub fn get_working_dir() -> &'static str {
230 WORING_DIR.deref()
231}
232
233pub 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
243pub 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 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 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 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 for children in dir_children.values_mut() {
299 children.sort_by(|a, b| {
300 if a.0 == "..." {
302 Ordering::Greater
303 } else if b.0 == "..." {
304 Ordering::Less
305 } else {
306 a.0.cmp(&b.0)
308 }
309 });
310 }
311
312 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
318fn 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 let mut path_components = vec![name.clone()];
338 let mut current_path = dir_path.join(name);
339
340 while let Some(children) = dir_children.get(¤t_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;
349 }
350 }
351 break;
353 }
354
355 let collapsed_name = path_components.join("/");
357 output.push_str(&format!("{prefix}{connector}{collapsed_name}/\n"));
358
359 build_tree_from_map(¤t_path, &new_prefix, output, dir_children);
361 } else {
362 output.push_str(&format!("{prefix}{connector}{name}\n"));
364 }
365 }
366}
367
368pub 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 let mut child = cmd
374 .spawn()
375 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
376
377 let status = tokio::select! {
379 biased;
381 _ = signal::ctrl_c() => {
383 tracing::info!("Received Ctrl+C, terminating child process...");
384 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
386 child.wait().await.with_context(|| "Failed to await child process after kill")?
388 }
389 status = child.wait() => {
391 status.with_context(|| format!("Child process for command `{command}` failed"))?
392 }
393 };
394
395 Ok(status)
396}
397
398pub 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 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 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 let mut terminated_by_signal = false;
423
424 let mut stdout_done = false;
426 let mut stderr_done = false;
427
428 while !stdout_done || !stderr_done {
430 tokio::select! {
431 biased;
433 _ = signal::ctrl_c() => {
435 tracing::info!("Received Ctrl+C, terminating child process...");
436 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
438 terminated_by_signal = true;
440 break;
442 },
443 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 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 else => break,
467 }
468 }
469
470 let status = child.wait().await.wrap_err("Failed to wait for command")?;
472
473 Ok((status, output_capture, terminated_by_signal))
474}
475
476pub fn prepare_command_execution(
478 command: &str,
479 output_command: bool,
480 include_prompt: bool,
481) -> color_eyre::Result<tokio::process::Command> {
482 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 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 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 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}