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 default = if cfg!(target_os = "windows") {
53 ShellType::WindowsPowerShell
54 } else {
55 ShellType::Sh
56 };
57
58 if cfg!(test) {
60 tracing::info!("Using default shell for tests: {default}");
61 return ShellInfo {
62 kind: default,
63 version: None,
64 };
65 }
66
67 let pid = Pid::from_u32(process::id());
69
70 tracing::debug!("Retrieving info for pid {pid}");
71 let sys = System::new_all();
72
73 let parent_process = sys
74 .process(Pid::from_u32(process::id()))
75 .expect("Couldn't retrieve current process from pid")
76 .parent()
77 .and_then(|parent_pid| sys.process(parent_pid));
78
79 let Some(parent) = parent_process else {
80 tracing::warn!("Couldn't detect shell, assuming {default}");
81 return ShellInfo {
82 kind: default,
83 version: None,
84 };
85 };
86
87 let parent_name = parent
88 .name()
89 .to_str()
90 .expect("Invalid parent shell name")
91 .trim()
92 .to_lowercase();
93
94 let kind = if parent_name == "cargo" || parent_name == "cargo.exe" {
95 tracing::warn!("Executed through cargo, assuming {default}");
96 return ShellInfo {
97 kind: default,
98 version: None,
99 };
100 } else {
101 ShellType::try_from(parent_name.as_str()).expect("infallible")
102 };
103
104 tracing::info!("Detected shell: {kind}");
105
106 let exe_path = parent
107 .exe()
108 .map(|p| p.as_os_str())
109 .filter(|p| !p.is_empty())
110 .unwrap_or_else(|| parent_name.as_ref());
111 let version = get_shell_version(&kind, exe_path).inspect(|v| tracing::info!("Detected shell version: {v}"));
112
113 ShellInfo { kind, version }
114});
115
116fn get_shell_version(shell_kind: &ShellType, shell_path: impl AsRef<OsStr>) -> Option<String> {
118 if *shell_kind == ShellType::Cmd {
120 return None;
121 }
122
123 let mut command = std::process::Command::new(shell_path);
125 if matches!(shell_kind, ShellType::PowerShellCore | ShellType::WindowsPowerShell) {
126 command.args([
127 "-Command",
128 "'PowerShell {0} ({1} Edition)' -f $PSVersionTable.PSVersion, $PSVersionTable.PSEdition",
129 ]);
130 } else {
131 command.arg("--version");
132 }
133
134 let mut child = match command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() {
136 Ok(child) => child,
137 Err(err) => {
138 tracing::warn!("Failed to spawn shell process: {err}");
139 return None;
140 }
141 };
142
143 match child.wait_timeout(Duration::from_millis(250)) {
145 Ok(Some(status)) => {
147 if status.success() {
148 let mut output = String::new();
149 if let Some(mut stdout) = child.stdout {
151 stdout.read_to_string(&mut output).unwrap_or_default();
152 }
153 Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
155 } else {
156 tracing::warn!("Shell version command failed with status: {}", status);
157 None
158 }
159 }
160 Ok(None) => {
162 if let Err(err) = child.kill() {
164 tracing::warn!("Failed to kill timed-out process: {err}");
165 }
166 tracing::warn!("Shell version command timed out");
167 None
168 }
169 Err(err) => {
171 tracing::warn!("Error waiting for shell version command: {err}");
172 None
173 }
174 }
175}
176
177pub fn get_shell_info() -> &'static ShellInfo {
179 PARENT_SHELL_INFO.deref()
180}
181
182pub fn get_shell_type() -> &'static ShellType {
184 &get_shell_info().kind
185}
186
187pub fn get_executable_version(root_cmd: impl AsRef<OsStr>) -> Option<String> {
189 if root_cmd.as_ref().is_empty() {
190 return None;
191 }
192
193 let mut child = std::process::Command::new(root_cmd)
195 .arg("--version")
196 .stdout(Stdio::piped())
197 .stderr(Stdio::piped())
198 .spawn()
199 .ok()?;
200
201 match child.wait_timeout(Duration::from_millis(250)) {
203 Ok(Some(status)) if status.success() => {
204 let mut output = String::new();
205 if let Some(mut stdout) = child.stdout {
206 stdout.read_to_string(&mut output).unwrap_or_default();
207 }
208 Some(output.lines().next().unwrap_or("").trim().to_string()).filter(|v| !v.is_empty())
209 }
210 Ok(None) => {
211 if let Err(err) = child.kill() {
212 tracing::warn!("Failed to kill timed-out process: {err}");
213 }
214 None
215 }
216 _ => None,
217 }
218}
219
220static OS_INFO: LazyLock<Info> = LazyLock::new(|| {
221 let info = os_info::get();
222 tracing::info!("Detected OS: {info}");
223 info
224});
225
226pub fn get_os_info() -> &'static Info {
228 &OS_INFO
229}
230
231static WORING_DIR: LazyLock<String> = LazyLock::new(|| {
232 std::env::current_dir()
233 .inspect_err(|err| tracing::warn!("Couldn't retrieve current dir: {err}"))
234 .ok()
235 .and_then(|p| p.to_str().map(|s| s.to_owned()))
236 .unwrap_or_default()
237});
238
239pub fn get_working_dir() -> &'static str {
241 WORING_DIR.deref()
242}
243
244pub fn format_env_var(var: impl AsRef<str>) -> String {
246 let var = var.as_ref();
247 match get_shell_type() {
248 ShellType::Cmd => format!("%{var}%"),
249 ShellType::WindowsPowerShell | ShellType::PowerShellCore => format!("$env:{var}"),
250 ShellType::Nushell => format!("$env.{var}"),
251 _ => format!("${var}"),
252 }
253}
254
255pub fn generate_working_dir_tree(max_depth: usize, entry_limit: usize) -> Option<String> {
257 let root = PathBuf::from(get_working_dir());
258 if !root.is_dir() {
259 return None;
260 }
261
262 let root_canon = root.canonicalize().ok()?;
263
264 let mut entries_by_depth: BTreeMap<usize, Vec<ignore::DirEntry>> = BTreeMap::new();
266 let mut total_child_counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
267 let walker = WalkBuilder::new(&root_canon).max_depth(Some(max_depth + 1)).build();
268
269 for entry in walker.flatten() {
270 if entry.depth() == 0 {
271 continue;
272 }
273 if let Some(parent_path) = entry.path().parent() {
274 *total_child_counts.entry(parent_path.to_path_buf()).or_default() += 1;
275 }
276 entries_by_depth.entry(entry.depth()).or_default().push(entry);
277 }
278
279 let mut limited_entries: Vec<ignore::DirEntry> = Vec::with_capacity(entry_limit);
281 'outer: for (_depth, entries) in entries_by_depth {
282 for entry in entries {
283 if limited_entries.len() >= entry_limit {
284 break 'outer;
285 }
286 limited_entries.push(entry);
287 }
288 }
289
290 let mut dir_children: BTreeMap<PathBuf, Vec<(String, bool)>> = BTreeMap::new();
292 for entry in limited_entries {
293 let is_dir = entry.path().is_dir();
294 if let Some(parent_path) = entry.path().parent() {
295 let file_name = entry.file_name().to_string_lossy().to_string();
296 dir_children
297 .entry(parent_path.to_path_buf())
298 .or_default()
299 .push((file_name, is_dir));
300 }
301 }
302 for (path, total_count) in total_child_counts {
303 let displayed_count = dir_children.get(&path).map_or(0, |v| v.len());
304 if displayed_count < total_count {
305 dir_children.entry(path).or_default().push(("...".to_string(), false));
306 }
307 }
308
309 for children in dir_children.values_mut() {
311 children.sort_by(|a, b| {
312 if a.0 == "..." {
314 Ordering::Greater
315 } else if b.0 == "..." {
316 Ordering::Less
317 } else {
318 a.0.cmp(&b.0)
320 }
321 });
322 }
323
324 let mut tree_string = format!("{} (current working dir)\n", root_canon.display());
326 build_tree_from_map(&root_canon, "", &mut tree_string, &dir_children);
327 Some(tree_string)
328}
329
330fn build_tree_from_map(
332 dir_path: &Path,
333 prefix: &str,
334 output: &mut String,
335 dir_children: &BTreeMap<PathBuf, Vec<(String, bool)>>,
336) {
337 let Some(entries) = dir_children.get(dir_path) else {
338 return;
339 };
340
341 let mut iter = entries.iter().peekable();
342 while let Some((name, is_dir)) = iter.next() {
343 let is_last = iter.peek().is_none();
344 let connector = if is_last { "└── " } else { "├── " };
345 let new_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
346
347 if *is_dir {
348 let mut path_components = vec![name.clone()];
350 let mut current_path = dir_path.join(name);
351
352 while let Some(children) = dir_children.get(¤t_path) {
354 if children.len() == 1 {
355 let (child_name, child_is_dir) = &children[0];
356 if *child_is_dir {
357 path_components.push(child_name.clone());
358 current_path.push(child_name);
359 continue;
361 }
362 }
363 break;
365 }
366
367 let collapsed_name = path_components.join("/");
369 output.push_str(&format!("{prefix}{connector}{collapsed_name}/\n"));
370
371 build_tree_from_map(¤t_path, &new_prefix, output, dir_children);
373 } else {
374 output.push_str(&format!("{prefix}{connector}{name}\n"));
376 }
377 }
378}
379
380pub async fn execute_shell_command_inherit(
382 command: &str,
383 include_prompt: bool,
384 cancellation_token: CancellationToken,
385) -> color_eyre::Result<ExitStatus> {
386 let mut cmd = prepare_command_execution(command, true, include_prompt)?;
387
388 let mut child = cmd
390 .spawn()
391 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
392
393 let status = tokio::select! {
395 biased;
397 _ = cancellation_token.cancelled() => {
399 tracing::info!("Received cancellation signal, terminating child process...");
400 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
402 child.wait().await.with_context(|| "Failed to await child process after kill")?
404 }
405 status = child.wait() => {
407 status.with_context(|| format!("Child process for command `{command}` failed"))?
408 }
409 };
410
411 Ok(status)
412}
413
414pub async fn execute_shell_command_capture(
418 command: &str,
419 include_prompt: bool,
420 cancellation_token: CancellationToken,
421) -> color_eyre::Result<(ExitStatus, String, bool)> {
422 let mut cmd = prepare_command_execution(command, true, include_prompt)?;
423
424 cmd.stdout(Stdio::piped());
426 cmd.stderr(Stdio::piped());
427
428 let mut child = cmd
429 .spawn()
430 .with_context(|| format!("Failed to spawn command: `{command}`"))?;
431
432 let mut stdout_reader = BufReader::new(child.stdout.take().unwrap()).lines();
434 let mut stderr_reader = BufReader::new(child.stderr.take().unwrap()).lines();
435
436 let mut output_capture = String::new();
437
438 let mut terminated_by_token = false;
440
441 let mut stdout_done = false;
443 let mut stderr_done = false;
444
445 while !stdout_done || !stderr_done {
447 tokio::select! {
448 biased;
450 _ = cancellation_token.cancelled() => {
452 tracing::info!("Received cancellation signal, terminating child process...");
453 child.kill().await.with_context(|| format!("Failed to kill child process for command: `{command}`"))?;
455 terminated_by_token = true;
457 break;
459 },
460 res = stdout_reader.next_line(), if !stdout_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 _ => stdout_done = true,
469 }
470 },
471 res = stderr_reader.next_line(), if !stderr_done => {
473 match res {
474 Ok(Some(line)) => {
475 writeln!(io::stderr(), "{line}")?;
476 output_capture.push_str(&line);
477 output_capture.push('\n');
478 },
479 _ => stderr_done = true,
480 }
481 },
482 else => break,
484 }
485 }
486
487 let status = child.wait().await.wrap_err("Failed to wait for command")?;
489
490 Ok((status, output_capture, terminated_by_token))
491}
492
493pub fn prepare_command_execution(
495 command: &str,
496 output_command: bool,
497 include_prompt: bool,
498) -> color_eyre::Result<tokio::process::Command> {
499 let shell = get_shell_type();
501 let shell_arg = match shell {
502 ShellType::Cmd => "/c",
503 ShellType::WindowsPowerShell => "-Command",
504 _ => "-c",
505 };
506
507 tracing::info!("Executing command: {shell} {shell_arg} -- {command}");
508
509 if output_command {
511 let write_result = if include_prompt {
512 writeln!(
513 io::stderr(),
514 "{}{command}",
515 env::var("INTELLI_EXEC_PROMPT").as_deref().unwrap_or("> "),
516 )
517 } else {
518 writeln!(io::stderr(), "{command}")
519 };
520 if let Err(err) = write_result {
522 if err.kind() != io::ErrorKind::BrokenPipe {
523 return Err(err).wrap_err("Failed writing to stderr");
524 }
525 tracing::error!("Failed writing to stderr: Broken pipe");
526 };
527 }
528
529 let mut cmd = tokio::process::Command::new(shell.to_string());
531 cmd.arg(shell_arg).arg(command).kill_on_drop(true);
532 Ok(cmd)
533}