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 Some(parent) = parent_process else {
63 let default = if cfg!(target_os = "windows") {
64 ShellType::WindowsPowerShell
65 } else {
66 ShellType::Sh
67 };
68 tracing::warn!("Couldn't detect shell, assuming {default}");
69 return ShellInfo {
70 kind: default,
71 version: None,
72 };
73 };
74
75 let parent_name = parent
76 .name()
77 .to_str()
78 .expect("Invalid parent shell name")
79 .trim()
80 .to_lowercase();
81
82 let kind = ShellType::try_from(parent_name.as_str()).expect("infallible");
83 tracing::info!("Detected shell: {kind}");
84
85 let exe_path = parent
86 .exe()
87 .map(|p| p.as_os_str())
88 .filter(|p| !p.is_empty())
89 .unwrap_or_else(|| parent_name.as_ref());
90 let version = get_shell_version(&kind, exe_path).inspect(|v| tracing::info!("Detected shell version: {v}"));
91
92 ShellInfo { kind, version }
93});
94
95fn get_shell_version(shell_kind: &ShellType, shell_path: impl AsRef<OsStr>) -> Option<String> {
97 if *shell_kind == ShellType::Cmd {
99 return None;
100 }
101
102 let mut command = std::process::Command::new(shell_path);
104 if matches!(shell_kind, ShellType::PowerShellCore | ShellType::WindowsPowerShell) {
105 command.args([
106 "-Command",
107 "'PowerShell {0} ({1} Edition)' -f $PSVersionTable.PSVersion, $PSVersionTable.PSEdition",
108 ]);
109 } else {
110 command.arg("--version");
111 }
112
113 let mut child = match command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() {
115 Ok(child) => child,
116 Err(err) => {
117 tracing::warn!("Failed to spawn shell process: {err}");
118 return None;
119 }
120 };
121
122 match child.wait_timeout(Duration::from_millis(250)) {
124 Ok(Some(status)) => {
126 if status.success() {
127 let mut output = String::new();
128 if let Some(mut stdout) = child.stdout {
130 stdout.read_to_string(&mut output).unwrap_or_default();
131 }
132 Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
134 } else {
135 tracing::warn!("Shell version command failed with status: {}", status);
136 None
137 }
138 }
139 Ok(None) => {
141 if let Err(err) = child.kill() {
143 tracing::warn!("Failed to kill timed-out process: {err}");
144 }
145 tracing::warn!("Shell version command timed out");
146 None
147 }
148 Err(err) => {
150 tracing::warn!("Error waiting for shell version command: {err}");
151 None
152 }
153 }
154}
155
156pub fn get_shell_info() -> &'static ShellInfo {
158 PARENT_SHELL_INFO.deref()
159}
160
161pub fn get_shell_type() -> &'static ShellType {
163 &get_shell_info().kind
164}
165
166pub fn get_executable_version(root_cmd: impl AsRef<OsStr>) -> Option<String> {
168 if root_cmd.as_ref().is_empty() {
169 return None;
170 }
171
172 let mut child = std::process::Command::new(root_cmd)
174 .arg("--version")
175 .stdout(Stdio::piped())
176 .stderr(Stdio::piped())
177 .spawn()
178 .ok()?;
179
180 match child.wait_timeout(Duration::from_millis(250)) {
182 Ok(Some(status)) if status.success() => {
183 let mut output = String::new();
184 if let Some(mut stdout) = child.stdout {
185 stdout.read_to_string(&mut output).unwrap_or_default();
186 }
187 Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
188 }
189 Ok(None) => {
190 if let Err(err) = child.kill() {
191 tracing::warn!("Failed to kill timed-out process: {err}");
192 }
193 None
194 }
195 _ => None,
196 }
197}
198
199static OS_INFO: LazyLock<Info> = LazyLock::new(|| {
200 let info = os_info::get();
201 tracing::info!("Detected OS: {info}");
202 info
203});
204
205pub fn get_os_info() -> &'static Info {
207 &OS_INFO
208}
209
210static WORING_DIR: LazyLock<String> = LazyLock::new(|| {
211 std::env::current_dir()
212 .inspect_err(|err| tracing::warn!("Couldn't retrieve current dir: {err}"))
213 .ok()
214 .and_then(|p| p.to_str().map(|s| s.to_owned()))
215 .unwrap_or_default()
216});
217
218pub fn get_working_dir() -> &'static str {
220 WORING_DIR.deref()
221}
222
223pub fn format_env_var(var: impl AsRef<str>) -> String {
225 let var = var.as_ref();
226 match get_shell_type() {
227 ShellType::Cmd => format!("%{var}%"),
228 ShellType::WindowsPowerShell | ShellType::PowerShellCore => format!("$env:{var}"),
229 _ => format!("${var}"),
230 }
231}
232
233pub fn generate_working_dir_tree(max_depth: usize, entry_limit: usize) -> Option<String> {
235 let root = PathBuf::from(get_working_dir());
236 if !root.is_dir() {
237 return None;
238 }
239
240 let root_canon = root.canonicalize().ok()?;
241
242 let mut entries_by_depth: BTreeMap<usize, Vec<ignore::DirEntry>> = BTreeMap::new();
244 let mut total_child_counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
245 let walker = WalkBuilder::new(&root_canon).max_depth(Some(max_depth + 1)).build();
246
247 for entry in walker.flatten() {
248 if entry.depth() == 0 {
249 continue;
250 }
251 if let Some(parent_path) = entry.path().parent() {
252 *total_child_counts.entry(parent_path.to_path_buf()).or_default() += 1;
253 }
254 entries_by_depth.entry(entry.depth()).or_default().push(entry);
255 }
256
257 let mut limited_entries: Vec<ignore::DirEntry> = Vec::with_capacity(entry_limit);
259 'outer: for (_depth, entries) in entries_by_depth {
260 for entry in entries {
261 if limited_entries.len() >= entry_limit {
262 break 'outer;
263 }
264 limited_entries.push(entry);
265 }
266 }
267
268 let mut dir_children: BTreeMap<PathBuf, Vec<(String, bool)>> = BTreeMap::new();
270 for entry in limited_entries {
271 let is_dir = entry.path().is_dir();
272 if let Some(parent_path) = entry.path().parent() {
273 let file_name = entry.file_name().to_string_lossy().to_string();
274 dir_children
275 .entry(parent_path.to_path_buf())
276 .or_default()
277 .push((file_name, is_dir));
278 }
279 }
280 for (path, total_count) in total_child_counts {
281 let displayed_count = dir_children.get(&path).map_or(0, |v| v.len());
282 if displayed_count < total_count {
283 dir_children.entry(path).or_default().push(("...".to_string(), false));
284 }
285 }
286
287 for children in dir_children.values_mut() {
289 children.sort_by(|a, b| {
290 if a.0 == "..." {
292 Ordering::Greater
293 } else if b.0 == "..." {
294 Ordering::Less
295 } else {
296 a.0.cmp(&b.0)
298 }
299 });
300 }
301
302 let mut tree_string = format!("{} (current working dir)\n", root_canon.display());
304 build_tree_from_map(&root_canon, "", &mut tree_string, &dir_children);
305 Some(tree_string)
306}
307
308fn build_tree_from_map(
310 dir_path: &Path,
311 prefix: &str,
312 output: &mut String,
313 dir_children: &BTreeMap<PathBuf, Vec<(String, bool)>>,
314) {
315 let Some(entries) = dir_children.get(dir_path) else {
316 return;
317 };
318
319 let mut iter = entries.iter().peekable();
320 while let Some((name, is_dir)) = iter.next() {
321 let is_last = iter.peek().is_none();
322 let connector = if is_last { "└── " } else { "├── " };
323 let new_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
324
325 if *is_dir {
326 let mut path_components = vec![name.clone()];
328 let mut current_path = dir_path.join(name);
329
330 while let Some(children) = dir_children.get(¤t_path) {
332 if children.len() == 1 {
333 let (child_name, child_is_dir) = &children[0];
334 if *child_is_dir {
335 path_components.push(child_name.clone());
336 current_path.push(child_name);
337 continue;
339 }
340 }
341 break;
343 }
344
345 let collapsed_name = path_components.join("/");
347 output.push_str(&format!("{prefix}{connector}{collapsed_name}/\n"));
348
349 build_tree_from_map(¤t_path, &new_prefix, output, dir_children);
351 } else {
352 output.push_str(&format!("{prefix}{connector}{name}\n"));
354 }
355 }
356}
357
358pub async fn execute_shell_command_inherit(command: &str, include_prompt: bool) -> color_eyre::Result<ExitStatus> {
360 let mut cmd = prepare_command_execution(command, include_prompt)?;
361
362 let mut child = cmd
364 .spawn()
365 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
366
367 let status = tokio::select! {
369 biased;
371 _ = signal::ctrl_c() => {
373 tracing::info!("Received Ctrl+C, terminating child process...");
374 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
376 child.wait().await.with_context(|| "Failed to await child process after kill")?
378 }
379 status = child.wait() => {
381 status.with_context(|| format!("Child process for command `{command}` failed"))?
382 }
383 };
384
385 Ok(status)
386}
387
388pub async fn execute_shell_command_capture(
392 command: &str,
393 include_prompt: bool,
394) -> color_eyre::Result<(ExitStatus, String, bool)> {
395 let mut cmd = prepare_command_execution(command, include_prompt)?;
396
397 cmd.stdout(Stdio::piped());
399 cmd.stderr(Stdio::piped());
400
401 let mut child = cmd
402 .spawn()
403 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
404
405 let mut stdout_reader = BufReader::new(child.stdout.take().unwrap()).lines();
407 let mut stderr_reader = BufReader::new(child.stderr.take().unwrap()).lines();
408
409 let mut output_capture = String::new();
410
411 let mut terminated_by_signal = false;
413
414 let mut stdout_done = false;
416 let mut stderr_done = false;
417
418 loop {
420 tokio::select! {
421 biased;
423 _ = signal::ctrl_c() => {
425 tracing::info!("Received Ctrl+C, terminating child process...");
426 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
428 terminated_by_signal = true;
430 break;
432 },
433 res = stdout_reader.next_line(), if !stdout_done => {
435 match res {
436 Ok(Some(line)) => {
437 writeln!(io::stderr(), "{line}")?;
438 output_capture.push_str(&line);
439 output_capture.push('\n');
440 },
441 _ => stdout_done = true,
442 }
443 },
444 res = stderr_reader.next_line(), if !stderr_done => {
446 match res {
447 Ok(Some(line)) => {
448 writeln!(io::stderr(), "{line}")?;
449 output_capture.push_str(&line);
450 output_capture.push('\n');
451 },
452 _ => stderr_done = true,
453 }
454 },
455 else => break,
457 }
458 }
459
460 let status = child.wait().await.wrap_err("Failed to wait for command")?;
462
463 Ok((status, output_capture, terminated_by_signal))
464}
465
466fn prepare_command_execution(command: &str, include_prompt: bool) -> color_eyre::Result<tokio::process::Command> {
468 let shell = get_shell_type();
470 let shell_arg = match shell {
471 ShellType::Cmd => "/c",
472 ShellType::WindowsPowerShell => "-Command",
473 _ => "-c",
474 };
475
476 tracing::info!("Executing command: {shell} {shell_arg} -- {command}");
477
478 let write_result = if include_prompt {
480 writeln!(
481 io::stderr(),
482 "{}{command}",
483 env::var("INTELLI_EXEC_PROMPT").as_deref().unwrap_or("> "),
484 )
485 } else {
486 writeln!(io::stderr(), "{command}")
487 };
488 if let Err(err) = write_result {
490 if err.kind() != io::ErrorKind::BrokenPipe {
491 return Err(err).wrap_err("Failed writing to stderr");
492 }
493 tracing::error!("Failed writing to stderr: Broken pipe");
494 };
495
496 let mut cmd = tokio::process::Command::new(shell.to_string());
498 cmd.arg(shell_arg).arg(command).kill_on_drop(true);
499 Ok(cmd)
500}