Skip to main content

ase_shell/
commands.rs

1//! Command parsing and execution.
2
3use std::{
4  env,
5  fs::{self, File},
6  io::Write,
7  path::{Path, PathBuf},
8  process::{ChildStdout, Command as OsCommand, Stdio},
9};
10
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13
14use anyhow::Context;
15use pathsearch::find_executable_in_path;
16use strum::{Display, EnumIs, EnumTryAs};
17
18mod parse;
19mod targets;
20
21pub use parse::{ParsedInvocation, needs_more_input};
22pub use targets::{StderrTarget, StdoutTarget, open_stderr_writer, open_writer};
23
24#[derive(Debug, PartialEq)]
25pub enum RunResult {
26  Continue,
27  Exit(u8),
28}
29
30const BUILTIN_NAMES: &[&str] = &["cd", "echo", "exit", "type", "pwd", "history"];
31
32fn is_builtin(name: &str) -> bool {
33  BUILTIN_NAMES.contains(&name)
34}
35pub fn run_line(raw: &str, shell_name: &str, history: &[String]) -> anyhow::Result<RunResult> {
36  let raw = raw.trim();
37  if raw.is_empty() {
38    return Ok(RunResult::Continue);
39  }
40
41  let tokens = match shlex::split(raw) {
42    Some(t) if !t.is_empty() => t,
43    _ => return Ok(RunResult::Continue),
44  };
45
46  if !tokens.iter().any(|t| t == "|") {
47    // No pipeline: fall back to existing single-command path.
48    let Some(cmd) = Cmd::from_input(raw)? else {
49      return Ok(RunResult::Continue);
50    };
51    return cmd.run(shell_name, history);
52  }
53
54  run_pipeline(tokens, shell_name)
55}
56
57#[derive(Debug, PartialEq, EnumIs, EnumTryAs, Display)]
58pub enum Cmd {
59  Cd {
60    cmd: Command,
61    stderr: StderrTarget,
62  },
63  Echo {
64    cmd: Command,
65    stdout: StdoutTarget,
66    stderr: StderrTarget,
67  },
68  Exit(u8),
69  Type {
70    cmd: Command,
71    stdout: StdoutTarget,
72    stderr: StderrTarget,
73  },
74  Exec {
75    cmd: Command,
76    stdout: StdoutTarget,
77    stderr: StderrTarget,
78  },
79  Pwd {
80    stdout: StdoutTarget,
81    stderr: StderrTarget,
82  },
83  History {
84    cmd: Command,
85    stdout: StdoutTarget,
86    stderr: StderrTarget,
87  },
88  Unknown {
89    cmd: Command,
90    stderr: StderrTarget,
91  },
92}
93
94impl Cmd {
95  pub fn from_input(raw: &str) -> anyhow::Result<Option<Self>> {
96    let raw = raw.trim();
97    if raw.is_empty() {
98      return Ok(None);
99    }
100    let tokens = shlex::split(raw).unwrap_or_default();
101    if tokens.is_empty() {
102      return Ok(None);
103    }
104    let Some(inv) = ParsedInvocation::from_tokens(tokens) else {
105      return Ok(None);
106    };
107    Ok(Some(Self::from_parts(
108      &inv.cmd_name,
109      inv.args,
110      inv.stdout,
111      inv.stderr,
112    )))
113  }
114
115  pub fn from_parts(
116    cmd_name: &str,
117    args: Vec<String>,
118    stdout: StdoutTarget,
119    stderr: StderrTarget,
120  ) -> Self {
121    match cmd_name {
122      "cd" => Cmd::Cd {
123        cmd: Command::new(cmd_name, None, args),
124        stderr,
125      },
126      "exit" => {
127        let code = args.first().and_then(|s| s.parse::<u8>().ok()).unwrap_or(0);
128        Cmd::Exit(code)
129      }
130      "echo" => Cmd::Echo {
131        cmd: Command::new(cmd_name, None, args),
132        stdout,
133        stderr,
134      },
135      "history" => Cmd::History {
136        cmd: Command::new(cmd_name, None, args),
137        stdout,
138        stderr,
139      },
140      "type" => Cmd::Type {
141        cmd: Command::new(cmd_name, None, args),
142        stdout,
143        stderr,
144      },
145      "pwd" => Cmd::Pwd { stdout, stderr },
146      _ => {
147        if cmd_name.contains('/') {
148          Cmd::Exec {
149            cmd: Command::new(cmd_name, Some(cmd_name.to_string()), args),
150            stdout,
151            stderr,
152          }
153        } else if let Some(path_buf) = find_executable(cmd_name) {
154          let path_str = path_buf
155            .into_os_string()
156            .into_string()
157            .unwrap_or_else(|_| String::new());
158          Cmd::Exec {
159            cmd: Command::new(cmd_name, Some(path_str), args),
160            stdout,
161            stderr,
162          }
163        } else {
164          Cmd::Unknown {
165            cmd: Command::new(cmd_name, None, args),
166            stderr,
167          }
168        }
169      }
170    }
171  }
172
173  pub fn run(&self, shell_name: &str, history: &[String]) -> anyhow::Result<RunResult> {
174    match self {
175      Cmd::Echo {
176        cmd,
177        stdout,
178        stderr: _,
179      } => {
180        let mut out = open_writer(stdout)?;
181        echo_args(&cmd.args, &mut out)?;
182        Ok(RunResult::Continue)
183      }
184      Cmd::Exit(code) => Ok(RunResult::Exit(*code)),
185      Cmd::Type {
186        cmd,
187        stdout,
188        stderr: _,
189      } => {
190        let mut out = open_writer(stdout)?;
191        writeln!(out, "{}", resolve_types(&cmd.args))?;
192        Ok(RunResult::Continue)
193      }
194      Cmd::Exec {
195        cmd,
196        stdout,
197        stderr,
198      } => {
199        if let Err(err) = cmd.run_with_stdio(stdout, stderr) {
200          let mut err_out = open_stderr_writer(stderr)?;
201          writeln!(err_out, "{shell_name}: {err}")?;
202        }
203        Ok(RunResult::Continue)
204      }
205      Cmd::Cd { cmd, stderr } => {
206        let target = cmd.args.first().map(String::as_str).unwrap_or("");
207        if let Err(err) = change_dir(target) {
208          let mut err_out = open_stderr_writer(stderr)?;
209          writeln!(err_out, "{shell_name}: {err}")?;
210        }
211        Ok(RunResult::Continue)
212      }
213      Cmd::Pwd { stdout, stderr: _ } => {
214        let mut out = open_writer(stdout)?;
215        let dir = env::current_dir().context("get current directory")?;
216        writeln!(out, "{}", dir.display())?;
217        Ok(RunResult::Continue)
218      }
219      Cmd::History {
220        cmd,
221        stdout,
222        stderr: _,
223      } => {
224        // Optional numeric argument: `history` or `history N`.
225        let count = cmd.args.get(0).and_then(|s| s.parse::<usize>().ok());
226        let total = history.len();
227        let start = match count {
228          Some(n) if n < total => total - n,
229          Some(_) => 0,
230          None => 0,
231        };
232
233        let mut out = open_writer(stdout)?;
234        for (idx, entry) in history.iter().enumerate().skip(start) {
235          writeln!(out, "  {:>4}  {entry}", idx + 1)?;
236        }
237        Ok(RunResult::Continue)
238      }
239      Cmd::Unknown { cmd, stderr } => {
240        let mut err_out = open_stderr_writer(stderr)?;
241        writeln!(err_out, "{shell_name}: command not found: {}", cmd.name)?;
242        Ok(RunResult::Continue)
243      }
244    }
245  }
246}
247
248fn split_pipeline(tokens: Vec<String>) -> Option<Vec<Vec<String>>> {
249  let mut segments = Vec::new();
250  let mut current = Vec::new();
251
252  for tok in tokens {
253    if tok == "|" {
254      if current.is_empty() {
255        return None;
256      }
257      segments.push(std::mem::take(&mut current));
258    } else {
259      current.push(tok);
260    }
261  }
262
263  if current.is_empty() {
264    return None;
265  }
266
267  segments.push(current);
268  Some(segments)
269}
270
271fn run_pipeline(tokens: Vec<String>, shell_name: &str) -> anyhow::Result<RunResult> {
272  use anyhow::anyhow;
273
274  let segments = match split_pipeline(tokens) {
275    Some(segs) => segs,
276    None => {
277      eprintln!("{shell_name}: invalid pipeline");
278      return Ok(RunResult::Continue);
279    }
280  };
281
282  let mut invocations = Vec::new();
283  for seg in segments {
284    let Some(inv) = ParsedInvocation::from_tokens(seg) else {
285      eprintln!("{shell_name}: invalid command in pipeline");
286      return Ok(RunResult::Continue);
287    };
288    invocations.push(inv);
289  }
290
291  if invocations.is_empty() {
292    return Ok(RunResult::Continue);
293  }
294
295  let mut children = Vec::new();
296  let mut prev_stdout: Option<ChildStdout> = None;
297
298  for (idx, inv) in invocations.iter().enumerate() {
299    let is_last = idx == invocations.len() - 1;
300
301    let program_path = if inv.cmd_name.contains('/') {
302      PathBuf::from(&inv.cmd_name)
303    } else if let Some(p) = find_executable(&inv.cmd_name) {
304      p
305    } else {
306      eprintln!("{shell_name}: command not found: {}", inv.cmd_name);
307      return Ok(RunResult::Continue);
308    };
309
310    let mut cmd = OsCommand::new(&program_path);
311    cmd.args(&inv.args);
312
313    if let Some(stdin) = prev_stdout.take() {
314      cmd.stdin(Stdio::from(stdin));
315    }
316
317    if is_last {
318      match &inv.stdout {
319        StdoutTarget::Stdout => { /* inherit */ }
320        StdoutTarget::Overwrite(path) => {
321          let file = File::create(path)?;
322          cmd.stdout(Stdio::from(file));
323        }
324        StdoutTarget::Append(path) => {
325          let file = File::options().append(true).create(true).open(path)?;
326          cmd.stdout(Stdio::from(file));
327        }
328      }
329    } else {
330      cmd.stdout(Stdio::piped());
331    }
332
333    if is_last {
334      match &inv.stderr {
335        StderrTarget::Stderr => { /* inherit */ }
336        StderrTarget::Overwrite(path) => {
337          let file = File::create(path)?;
338          cmd.stderr(Stdio::from(file));
339        }
340        StderrTarget::Append(path) => {
341          let file = File::options().append(true).create(true).open(path)?;
342          cmd.stderr(Stdio::from(file));
343        }
344      }
345    }
346
347    let mut child = cmd
348      .spawn()
349      .with_context(|| format!("failed to spawn `{}`", inv.cmd_name))?;
350
351    if !is_last {
352      let child_stdout = child
353        .stdout
354        .take()
355        .ok_or_else(|| anyhow!("failed to capture stdout for pipeline stage"))?;
356      prev_stdout = Some(child_stdout);
357    }
358
359    children.push(child);
360  }
361
362  for mut child in children {
363    child
364      .wait()
365      .with_context(|| "failed to wait for pipeline stage")?;
366  }
367
368  Ok(RunResult::Continue)
369}
370
371#[derive(Debug, PartialEq, Clone)]
372pub struct Command {
373  pub name: String,
374  pub path: Option<String>,
375  pub args: Vec<String>,
376}
377
378impl Command {
379  pub fn new(name: &str, path: Option<String>, args: Vec<String>) -> Self {
380    Self {
381      name: name.to_owned(),
382      path,
383      args,
384    }
385  }
386
387  pub fn run(&self) -> anyhow::Result<()> {
388    let program = self.path.as_deref().unwrap_or(&self.name);
389    let mut child = std::process::Command::new(program)
390      .args(&self.args)
391      .spawn()
392      .with_context(|| format!("failed to spawn `{program}`"))?;
393    child
394      .wait()
395      .with_context(|| format!("failed to wait for `{program}`"))?;
396    Ok(())
397  }
398
399  pub fn run_with_stdio(&self, stdout: &StdoutTarget, stderr: &StderrTarget) -> anyhow::Result<()> {
400    let program = self.path.as_deref().unwrap_or(&self.name);
401    let mut command = std::process::Command::new(program);
402    command.args(&self.args);
403
404    match stdout {
405      StdoutTarget::Stdout => {}
406      StdoutTarget::Overwrite(path) => {
407        let file = File::create(path)?;
408        command.stdout(Stdio::from(file));
409      }
410      StdoutTarget::Append(path) => {
411        let file = File::options().append(true).create(true).open(path)?;
412        command.stdout(Stdio::from(file));
413      }
414    }
415
416    match stderr {
417      StderrTarget::Stderr => {}
418      StderrTarget::Overwrite(path) => {
419        let file = File::create(path)?;
420        command.stderr(Stdio::from(file));
421      }
422      StderrTarget::Append(path) => {
423        let file = File::options().append(true).create(true).open(path)?;
424        command.stderr(Stdio::from(file));
425      }
426    }
427
428    let mut child = command
429      .spawn()
430      .with_context(|| format!("failed to spawn `{program}`"))?;
431    child
432      .wait()
433      .with_context(|| format!("failed to wait for `{program}`"))?;
434    Ok(())
435  }
436}
437
438pub fn resolve_types(args: &[String]) -> String {
439  args
440    .iter()
441    .map(|name| {
442      if is_builtin(name) {
443        format!("{name} is a shell builtin")
444      } else {
445        match find_executable(name) {
446          Some(path) => format!("{name} is {}", path.display()),
447          None => format!("{name}: not found"),
448        }
449      }
450    })
451    .collect::<Vec<String>>()
452    .join("\n")
453}
454
455pub fn find_executable(cmd: &str) -> Option<PathBuf> {
456  find_executable_in_path(cmd)
457}
458
459/// Returns sorted, deduplicated command names (builtins + PATH executables) that start with `prefix`.
460/// Used for tab completion; only the first word of the line should be completed.
461pub fn complete_command(prefix: &str) -> Vec<String> {
462  let mut names: Vec<String> = BUILTIN_NAMES
463    .iter()
464    .filter(|n| n.starts_with(prefix))
465    .map(|s| (*s).to_string())
466    .collect();
467
468  let path_var = env::var("PATH").unwrap_or_default();
469  for dir in env::split_paths(&path_var) {
470    let Ok(entries) = fs::read_dir(&dir) else {
471      continue;
472    };
473    for entry in entries.flatten() {
474      let path = entry.path();
475      let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
476        continue;
477      };
478      if !name.starts_with(prefix) {
479        continue;
480      }
481      let meta = match entry.metadata() {
482        Ok(m) => m,
483        Err(_) => continue,
484      };
485      if meta.is_dir() {
486        continue;
487      }
488      #[cfg(unix)]
489      if meta.permissions().mode() & 0o111 == 0 {
490        continue;
491      }
492      names.push(name.to_string());
493    }
494  }
495
496  names.sort_unstable();
497  names.dedup();
498  names
499}
500
501pub fn change_dir(target: &str) -> anyhow::Result<()> {
502  let new_path = if target.is_empty() || target == "~" {
503    env::var("HOME").context("HOME not set")?
504  } else {
505    target.to_string()
506  };
507
508  let path = Path::new(&new_path);
509
510  if !path.exists() {
511    println!("cd: {target}: No such file or directory");
512    return Ok(());
513  }
514
515  env::set_current_dir(path).with_context(|| format!("cd: {target}"))?;
516
517  let updated_cwd = env::current_dir().context("get cwd after cd")?;
518  unsafe {
519    env::set_var("PWD", updated_cwd);
520  }
521
522  Ok(())
523}
524
525pub fn echo_args<W: Write>(args: &[String], out: &mut W) -> anyhow::Result<()> {
526  writeln!(out, "{}", args.join(" "))?;
527  Ok(())
528}
529
530#[cfg(test)]
531mod tests {
532  use super::*;
533
534  #[test]
535  fn from_input_empty_returns_none() {
536    assert!(matches!(Cmd::from_input("").unwrap(), None));
537    assert!(matches!(Cmd::from_input("   ").unwrap(), None));
538  }
539
540  #[test]
541  fn from_input_echo_preserves_whitespace() {
542    let cmd = Cmd::from_input(r#"echo "hello   world""#).unwrap().unwrap();
543    assert!(cmd.is_echo());
544    let Cmd::Echo { cmd, .. } = cmd else {
545      unreachable!()
546    };
547    assert_eq!(cmd.args, vec!["hello   world"]);
548  }
549
550  #[test]
551  fn from_input_echo_multiple_args() {
552    let cmd = Cmd::from_input("echo a b c").unwrap().unwrap();
553    let Cmd::Echo { cmd, .. } = cmd else {
554      unreachable!()
555    };
556    assert_eq!(cmd.args, vec!["a", "b", "c"]);
557  }
558
559  #[test]
560  fn needs_more_input_unclosed_quotes() {
561    assert!(needs_more_input(r#"echo "hello"#));
562    assert!(needs_more_input("echo 'hello"));
563    assert!(!needs_more_input(r#"echo "hello""#));
564    assert!(!needs_more_input(""));
565  }
566
567  #[test]
568  fn from_parts_exit_no_args_is_zero() {
569    let cmd = Cmd::from_parts("exit", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
570    assert!(matches!(cmd, Cmd::Exit(0)));
571  }
572
573  #[test]
574  fn from_parts_exit_with_code() {
575    let cmd = Cmd::from_parts(
576      "exit",
577      vec!["42".into()],
578      StdoutTarget::Stdout,
579      StderrTarget::Stderr,
580    );
581    assert!(matches!(cmd, Cmd::Exit(42)));
582  }
583
584  #[test]
585  fn from_parts_pwd() {
586    let cmd = Cmd::from_parts("pwd", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
587    assert!(matches!(cmd, Cmd::Pwd { .. }));
588  }
589
590  #[test]
591  fn from_parts_type_args() {
592    let cmd = Cmd::from_parts(
593      "type",
594      vec!["cd".into(), "ls".into()],
595      StdoutTarget::Stdout,
596      StderrTarget::Stderr,
597    );
598    let Cmd::Type { cmd, .. } = cmd else {
599      unreachable!()
600    };
601    assert_eq!(cmd.args, vec!["cd", "ls"]);
602  }
603
604  #[test]
605  fn from_parts_cd_args() {
606    let cmd = Cmd::from_parts(
607      "cd",
608      vec!["/tmp".into()],
609      StdoutTarget::Stdout,
610      StderrTarget::Stderr,
611    );
612    let Cmd::Cd { cmd, .. } = cmd else {
613      unreachable!()
614    };
615    assert_eq!(cmd.args, vec!["/tmp"]);
616  }
617
618  #[test]
619  fn is_builtin_known() {
620    for name in BUILTIN_NAMES {
621      assert!(is_builtin(name), "{name} should be builtin");
622    }
623  }
624
625  #[test]
626  fn is_builtin_unknown() {
627    assert!(!is_builtin("ls"));
628    assert!(!is_builtin(""));
629  }
630}