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 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 ShellType::Nushell => format!("$env.{var}"),
240 _ => format!("${var}"),
241 }
242}
243
244pub fn generate_working_dir_tree(max_depth: usize, entry_limit: usize) -> Option<String> {
246 let root = PathBuf::from(get_working_dir());
247 if !root.is_dir() {
248 return None;
249 }
250
251 let root_canon = root.canonicalize().ok()?;
252
253 let mut entries_by_depth: BTreeMap<usize, Vec<ignore::DirEntry>> = BTreeMap::new();
255 let mut total_child_counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
256 let walker = WalkBuilder::new(&root_canon).max_depth(Some(max_depth + 1)).build();
257
258 for entry in walker.flatten() {
259 if entry.depth() == 0 {
260 continue;
261 }
262 if let Some(parent_path) = entry.path().parent() {
263 *total_child_counts.entry(parent_path.to_path_buf()).or_default() += 1;
264 }
265 entries_by_depth.entry(entry.depth()).or_default().push(entry);
266 }
267
268 let mut limited_entries: Vec<ignore::DirEntry> = Vec::with_capacity(entry_limit);
270 'outer: for (_depth, entries) in entries_by_depth {
271 for entry in entries {
272 if limited_entries.len() >= entry_limit {
273 break 'outer;
274 }
275 limited_entries.push(entry);
276 }
277 }
278
279 let mut dir_children: BTreeMap<PathBuf, Vec<(String, bool)>> = BTreeMap::new();
281 for entry in limited_entries {
282 let is_dir = entry.path().is_dir();
283 if let Some(parent_path) = entry.path().parent() {
284 let file_name = entry.file_name().to_string_lossy().to_string();
285 dir_children
286 .entry(parent_path.to_path_buf())
287 .or_default()
288 .push((file_name, is_dir));
289 }
290 }
291 for (path, total_count) in total_child_counts {
292 let displayed_count = dir_children.get(&path).map_or(0, |v| v.len());
293 if displayed_count < total_count {
294 dir_children.entry(path).or_default().push(("...".to_string(), false));
295 }
296 }
297
298 for children in dir_children.values_mut() {
300 children.sort_by(|a, b| {
301 if a.0 == "..." {
303 Ordering::Greater
304 } else if b.0 == "..." {
305 Ordering::Less
306 } else {
307 a.0.cmp(&b.0)
309 }
310 });
311 }
312
313 let mut tree_string = format!("{} (current working dir)\n", root_canon.display());
315 build_tree_from_map(&root_canon, "", &mut tree_string, &dir_children);
316 Some(tree_string)
317}
318
319fn build_tree_from_map(
321 dir_path: &Path,
322 prefix: &str,
323 output: &mut String,
324 dir_children: &BTreeMap<PathBuf, Vec<(String, bool)>>,
325) {
326 let Some(entries) = dir_children.get(dir_path) else {
327 return;
328 };
329
330 let mut iter = entries.iter().peekable();
331 while let Some((name, is_dir)) = iter.next() {
332 let is_last = iter.peek().is_none();
333 let connector = if is_last { "└── " } else { "├── " };
334 let new_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
335
336 if *is_dir {
337 let mut path_components = vec![name.clone()];
339 let mut current_path = dir_path.join(name);
340
341 while let Some(children) = dir_children.get(¤t_path) {
343 if children.len() == 1 {
344 let (child_name, child_is_dir) = &children[0];
345 if *child_is_dir {
346 path_components.push(child_name.clone());
347 current_path.push(child_name);
348 continue;
350 }
351 }
352 break;
354 }
355
356 let collapsed_name = path_components.join("/");
358 output.push_str(&format!("{prefix}{connector}{collapsed_name}/\n"));
359
360 build_tree_from_map(¤t_path, &new_prefix, output, dir_children);
362 } else {
363 output.push_str(&format!("{prefix}{connector}{name}\n"));
365 }
366 }
367}
368
369pub async fn execute_shell_command_inherit(
371 command: &str,
372 include_prompt: bool,
373 cancellation_token: CancellationToken,
374) -> color_eyre::Result<ExitStatus> {
375 let mut cmd = prepare_command_execution(command, true, include_prompt)?;
376
377 let mut child = cmd
379 .spawn()
380 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
381
382 let status = tokio::select! {
384 biased;
386 _ = cancellation_token.cancelled() => {
388 tracing::info!("Received cancellation signal, terminating child process...");
389 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
391 child.wait().await.with_context(|| "Failed to await child process after kill")?
393 }
394 status = child.wait() => {
396 status.with_context(|| format!("Child process for command `{command}` failed"))?
397 }
398 };
399
400 Ok(status)
401}
402
403pub async fn execute_shell_command_capture(
407 command: &str,
408 include_prompt: bool,
409 cancellation_token: CancellationToken,
410) -> color_eyre::Result<(ExitStatus, String, bool)> {
411 let mut cmd = prepare_command_execution(command, true, include_prompt)?;
412
413 cmd.stdout(Stdio::piped());
415 cmd.stderr(Stdio::piped());
416
417 let mut child = cmd
418 .spawn()
419 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
420
421 let mut stdout_reader = BufReader::new(child.stdout.take().unwrap()).lines();
423 let mut stderr_reader = BufReader::new(child.stderr.take().unwrap()).lines();
424
425 let mut output_capture = String::new();
426
427 let mut terminated_by_token = false;
429
430 let mut stdout_done = false;
432 let mut stderr_done = false;
433
434 while !stdout_done || !stderr_done {
436 tokio::select! {
437 biased;
439 _ = cancellation_token.cancelled() => {
441 tracing::info!("Received cancellation signal, terminating child process...");
442 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
444 terminated_by_token = true;
446 break;
448 },
449 res = stdout_reader.next_line(), if !stdout_done => {
451 match res {
452 Ok(Some(line)) => {
453 writeln!(io::stderr(), "{line}")?;
454 output_capture.push_str(&line);
455 output_capture.push('\n');
456 },
457 _ => stdout_done = true,
458 }
459 },
460 res = stderr_reader.next_line(), if !stderr_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 _ => stderr_done = true,
469 }
470 },
471 else => break,
473 }
474 }
475
476 let status = child.wait().await.wrap_err("Failed to wait for command")?;
478
479 Ok((status, output_capture, terminated_by_token))
480}
481
482pub fn prepare_command_execution(
484 command: &str,
485 output_command: bool,
486 include_prompt: bool,
487) -> color_eyre::Result<tokio::process::Command> {
488 let shell = get_shell_type();
490 let shell_arg = match shell {
491 ShellType::Cmd => "/c",
492 ShellType::WindowsPowerShell => "-Command",
493 _ => "-c",
494 };
495
496 tracing::info!("Executing command: {shell} {shell_arg} -- {command}");
497
498 if output_command {
500 let write_result = if include_prompt {
501 writeln!(
502 io::stderr(),
503 "{}{command}",
504 env::var("INTELLI_EXEC_PROMPT").as_deref().unwrap_or("> "),
505 )
506 } else {
507 writeln!(io::stderr(), "{command}")
508 };
509 if let Err(err) = write_result {
511 if err.kind() != io::ErrorKind::BrokenPipe {
512 return Err(err).wrap_err("Failed writing to stderr");
513 }
514 tracing::error!("Failed writing to stderr: Broken pipe");
515 };
516 }
517
518 let mut cmd = tokio::process::Command::new(shell.to_string());
520 cmd.arg(shell_arg).arg(command).kill_on_drop(true);
521 Ok(cmd)
522}