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}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
37enum ControlOp {
38  AndAnd,
39  OrOr,
40}
41
42/// Split a line by `;`, respecting quotes. Returns segments (trimmed).
43fn split_by_semicolon(s: &str) -> Vec<String> {
44  let mut result = Vec::new();
45  let mut start = 0;
46  let mut in_double = false;
47  let mut in_single = false;
48  let bytes = s.as_bytes();
49  let mut i = 0;
50  while i < bytes.len() {
51    let c = bytes[i] as char;
52    match c {
53      '"' if !in_single => in_double = !in_double,
54      '\'' if !in_double => in_single = !in_single,
55      ';' if !in_double && !in_single => {
56        result.push(s[start..i].trim().to_string());
57        start = i + 1;
58      }
59      _ => {}
60    }
61    i += 1;
62  }
63  result.push(s[start..].trim().to_string());
64  result
65}
66
67/// Split a segment by `&&` and `||`, respecting quotes. Returns (segments, operators).
68/// Does not split on single `|` (pipeline).
69fn split_by_and_or(s: &str) -> (Vec<String>, Vec<ControlOp>) {
70  let mut segments = Vec::new();
71  let mut ops = Vec::new();
72  let mut start = 0;
73  let mut in_double = false;
74  let mut in_single = false;
75  let bytes = s.as_bytes();
76  let mut i = 0;
77  while i < bytes.len() {
78    let c = bytes[i] as char;
79    match c {
80      '"' if !in_single => in_double = !in_double,
81      '\'' if !in_double => in_single = !in_single,
82      '&' if !in_double && !in_single && i + 1 < bytes.len() && bytes[i + 1] == b'&' => {
83        segments.push(s[start..i].trim().to_string());
84        ops.push(ControlOp::AndAnd);
85        i += 1;
86        start = i + 1;
87      }
88      '|' if !in_double && !in_single && i + 1 < bytes.len() && bytes[i + 1] == b'|' => {
89        segments.push(s[start..i].trim().to_string());
90        ops.push(ControlOp::OrOr);
91        i += 1;
92        start = i + 1;
93      }
94      _ => {}
95    }
96    i += 1;
97  }
98  segments.push(s[start..].trim().to_string());
99  (segments, ops)
100}
101
102/// Run a single part (may contain pipelines) and return (RunResult, exit_status).
103fn run_one_part(
104  raw: &str,
105  shell_name: &str,
106  history: &[String],
107) -> anyhow::Result<(RunResult, u8)> {
108  let raw = raw.trim();
109  if raw.is_empty() {
110    return Ok((RunResult::Continue, 0));
111  }
112
113  let tokens = match shlex::split(raw) {
114    Some(t) if !t.is_empty() => t,
115    _ => return Ok((RunResult::Continue, 0)),
116  };
117
118  if tokens.iter().any(|t| t == "|") {
119    let status = run_pipeline_for_status(tokens, shell_name)?;
120    return Ok((RunResult::Continue, status));
121  }
122
123  let Some(cmd) = Cmd::from_input(raw)? else {
124    return Ok((RunResult::Continue, 0));
125  };
126  cmd.run_with_status(shell_name, history)
127}
128
129pub fn run_line(raw: &str, shell_name: &str, history: &[String]) -> anyhow::Result<RunResult> {
130  let raw = raw.trim();
131  if raw.is_empty() {
132    return Ok(RunResult::Continue);
133  }
134
135  // Split by `;` first (lowest precedence)
136  let semicolon_segments = split_by_semicolon(raw);
137
138  let mut last_status = 0u8;
139  for segment in semicolon_segments {
140    let seg = segment.trim();
141    if seg.is_empty() {
142      continue;
143    }
144    // Split by `&&` and `||` within this segment
145    let (parts, ops) = split_by_and_or(seg);
146    if parts.is_empty() || parts.iter().all(|p| p.is_empty()) {
147      continue;
148    }
149
150    for (idx, part) in parts.iter().enumerate() {
151      let part = part.trim();
152      if part.is_empty() {
153        continue;
154      }
155      // Operator *before* this part (between previous and this)
156      let op = if idx == 0 { None } else { ops.get(idx - 1) };
157      let should_run = match op {
158        None => true, // first part
159        Some(ControlOp::AndAnd) => last_status == 0,
160        Some(ControlOp::OrOr) => last_status != 0,
161      };
162      if !should_run {
163        if op == Some(&ControlOp::AndAnd) {
164          continue; // keep last_status
165        }
166        if op == Some(&ControlOp::OrOr) {
167          break; // OrOr: we skipped because last_status was 0
168        }
169        continue;
170      }
171
172      let (result, status) = run_one_part(part, shell_name, history)?;
173      last_status = status;
174
175      if let RunResult::Exit(code) = result {
176        return Ok(RunResult::Exit(code));
177      }
178    }
179  }
180
181  Ok(RunResult::Continue)
182}
183
184#[derive(Debug, PartialEq, EnumIs, EnumTryAs, Display)]
185pub enum Cmd {
186  Cd {
187    cmd: Command,
188    stderr: StderrTarget,
189  },
190  Echo {
191    cmd: Command,
192    stdout: StdoutTarget,
193    stderr: StderrTarget,
194  },
195  Exit(u8),
196  Type {
197    cmd: Command,
198    stdout: StdoutTarget,
199    stderr: StderrTarget,
200  },
201  Exec {
202    cmd: Command,
203    stdout: StdoutTarget,
204    stderr: StderrTarget,
205  },
206  Pwd {
207    stdout: StdoutTarget,
208    stderr: StderrTarget,
209  },
210  History {
211    cmd: Command,
212    stdout: StdoutTarget,
213    stderr: StderrTarget,
214  },
215  Unknown {
216    cmd: Command,
217    stderr: StderrTarget,
218  },
219}
220
221impl Cmd {
222  pub fn from_input(raw: &str) -> anyhow::Result<Option<Self>> {
223    let raw = raw.trim();
224    if raw.is_empty() {
225      return Ok(None);
226    }
227    let tokens = shlex::split(raw).unwrap_or_default();
228    if tokens.is_empty() {
229      return Ok(None);
230    }
231    let Some(inv) = ParsedInvocation::from_tokens(tokens) else {
232      return Ok(None);
233    };
234    Ok(Some(Self::from_parts(
235      &inv.cmd_name,
236      inv.args,
237      inv.stdout,
238      inv.stderr,
239    )))
240  }
241
242  pub fn from_parts(
243    cmd_name: &str,
244    args: Vec<String>,
245    stdout: StdoutTarget,
246    stderr: StderrTarget,
247  ) -> Self {
248    match cmd_name {
249      "cd" => Cmd::Cd {
250        cmd: Command::new(cmd_name, None, args),
251        stderr,
252      },
253      "exit" => {
254        let code = args.first().and_then(|s| s.parse::<u8>().ok()).unwrap_or(0);
255        Cmd::Exit(code)
256      }
257      "echo" => Cmd::Echo {
258        cmd: Command::new(cmd_name, None, args),
259        stdout,
260        stderr,
261      },
262      "history" => Cmd::History {
263        cmd: Command::new(cmd_name, None, args),
264        stdout,
265        stderr,
266      },
267      "type" => Cmd::Type {
268        cmd: Command::new(cmd_name, None, args),
269        stdout,
270        stderr,
271      },
272      "pwd" => Cmd::Pwd { stdout, stderr },
273      _ => {
274        if cmd_name.contains('/') {
275          Cmd::Exec {
276            cmd: Command::new(cmd_name, Some(cmd_name.to_string()), args),
277            stdout,
278            stderr,
279          }
280        } else if let Some(path_buf) = find_executable(cmd_name) {
281          let path_str = path_buf
282            .into_os_string()
283            .into_string()
284            .unwrap_or_else(|_| String::new());
285          Cmd::Exec {
286            cmd: Command::new(cmd_name, Some(path_str), args),
287            stdout,
288            stderr,
289          }
290        } else {
291          Cmd::Unknown {
292            cmd: Command::new(cmd_name, None, args),
293            stderr,
294          }
295        }
296      }
297    }
298  }
299
300  /// Run the command and return (RunResult, exit_status). Exit status is used for `&&` / `||`.
301  pub fn run_with_status(
302    &self,
303    shell_name: &str,
304    history: &[String],
305  ) -> anyhow::Result<(RunResult, u8)> {
306    match self {
307      Cmd::Echo {
308        cmd,
309        stdout,
310        stderr: _,
311      } => {
312        let mut out = open_writer(stdout)?;
313        echo_args(&cmd.args, &mut out)?;
314        Ok((RunResult::Continue, 0))
315      }
316      Cmd::Exit(code) => Ok((RunResult::Exit(*code), *code)),
317      Cmd::Type {
318        cmd,
319        stdout,
320        stderr: _,
321      } => {
322        let mut out = open_writer(stdout)?;
323        writeln!(out, "{}", resolve_types(&cmd.args))?;
324        Ok((RunResult::Continue, 0))
325      }
326      Cmd::Exec {
327        cmd,
328        stdout,
329        stderr,
330      } => {
331        let status = match cmd.run_with_stdio(stdout, stderr) {
332          Ok(s) => s,
333          Err(err) => {
334            let mut err_out = open_stderr_writer(stderr)?;
335            writeln!(err_out, "{shell_name}: {err}")?;
336            127
337          }
338        };
339        Ok((RunResult::Continue, status))
340      }
341      Cmd::Cd { cmd, stderr } => {
342        let target = cmd.args.first().map(String::as_str).unwrap_or("");
343        let status = match change_dir(target) {
344          Ok(()) => 0,
345          Err(err) => {
346            let mut err_out = open_stderr_writer(stderr)?;
347            writeln!(err_out, "{shell_name}: {err}")?;
348            1
349          }
350        };
351        Ok((RunResult::Continue, status))
352      }
353      Cmd::Pwd { stdout, stderr: _ } => {
354        let mut out = open_writer(stdout)?;
355        let dir = env::current_dir().context("get current directory")?;
356        writeln!(out, "{}", dir.display())?;
357        Ok((RunResult::Continue, 0))
358      }
359      Cmd::History {
360        cmd,
361        stdout,
362        stderr: _,
363      } => {
364        let count = cmd.args.get(0).and_then(|s| s.parse::<usize>().ok());
365        let total = history.len();
366        let start = match count {
367          Some(n) if n < total => total - n,
368          Some(_) => 0,
369          None => 0,
370        };
371
372        let mut out = open_writer(stdout)?;
373        for (idx, entry) in history.iter().enumerate().skip(start) {
374          writeln!(out, "  {:>4}  {entry}", idx + 1)?;
375        }
376        Ok((RunResult::Continue, 0))
377      }
378      Cmd::Unknown { cmd, stderr } => {
379        let mut err_out = open_stderr_writer(stderr)?;
380        writeln!(err_out, "{shell_name}: command not found: {}", cmd.name)?;
381        Ok((RunResult::Continue, 127))
382      }
383    }
384  }
385
386  pub fn run(&self, shell_name: &str, history: &[String]) -> anyhow::Result<RunResult> {
387    self.run_with_status(shell_name, history).map(|(r, _)| r)
388  }
389}
390
391fn split_pipeline(tokens: Vec<String>) -> Option<Vec<Vec<String>>> {
392  let mut segments = Vec::new();
393  let mut current = Vec::new();
394
395  for tok in tokens {
396    if tok == "|" {
397      if current.is_empty() {
398        return None;
399      }
400      segments.push(std::mem::take(&mut current));
401    } else {
402      current.push(tok);
403    }
404  }
405
406  if current.is_empty() {
407    return None;
408  }
409
410  segments.push(current);
411  Some(segments)
412}
413
414fn run_pipeline_for_status(tokens: Vec<String>, shell_name: &str) -> anyhow::Result<u8> {
415  use anyhow::anyhow;
416
417  let segments = match split_pipeline(tokens) {
418    Some(segs) => segs,
419    None => {
420      eprintln!("{shell_name}: invalid pipeline");
421      return Ok(127);
422    }
423  };
424
425  let mut invocations = Vec::new();
426  for seg in segments {
427    let Some(inv) = ParsedInvocation::from_tokens(seg) else {
428      eprintln!("{shell_name}: invalid command in pipeline");
429      return Ok(127);
430    };
431    invocations.push(inv);
432  }
433
434  if invocations.is_empty() {
435    return Ok(127);
436  }
437
438  let mut children = Vec::new();
439  let mut prev_stdout: Option<ChildStdout> = None;
440
441  for (idx, inv) in invocations.iter().enumerate() {
442    let is_last = idx == invocations.len() - 1;
443
444    let program_path = if inv.cmd_name.contains('/') {
445      PathBuf::from(&inv.cmd_name)
446    } else if let Some(p) = find_executable(&inv.cmd_name) {
447      p
448    } else {
449      eprintln!("{shell_name}: command not found: {}", inv.cmd_name);
450      return Ok(127);
451    };
452
453    let mut cmd = OsCommand::new(&program_path);
454    cmd.args(&inv.args);
455
456    if let Some(stdin) = prev_stdout.take() {
457      cmd.stdin(Stdio::from(stdin));
458    }
459
460    if is_last {
461      match &inv.stdout {
462        StdoutTarget::Stdout => {}
463        StdoutTarget::Overwrite(path) => {
464          let file = File::create(path)?;
465          cmd.stdout(Stdio::from(file));
466        }
467        StdoutTarget::Append(path) => {
468          let file = File::options().append(true).create(true).open(path)?;
469          cmd.stdout(Stdio::from(file));
470        }
471      }
472    } else {
473      cmd.stdout(Stdio::piped());
474    }
475
476    if is_last {
477      match &inv.stderr {
478        StderrTarget::Stderr => {}
479        StderrTarget::Overwrite(path) => {
480          let file = File::create(path)?;
481          cmd.stderr(Stdio::from(file));
482        }
483        StderrTarget::Append(path) => {
484          let file = File::options().append(true).create(true).open(path)?;
485          cmd.stderr(Stdio::from(file));
486        }
487      }
488    }
489
490    let mut child = cmd
491      .spawn()
492      .with_context(|| format!("failed to spawn `{}`", inv.cmd_name))?;
493
494    if !is_last {
495      let child_stdout = child
496        .stdout
497        .take()
498        .ok_or_else(|| anyhow!("failed to capture stdout for pipeline stage"))?;
499      prev_stdout = Some(child_stdout);
500    }
501
502    children.push(child);
503  }
504
505  let mut last_status = 127u8;
506  for (i, mut child) in children.into_iter().enumerate() {
507    let exit_status = child
508      .wait()
509      .with_context(|| "failed to wait for pipeline stage")?;
510    if i == invocations.len() - 1 {
511      last_status = (exit_status.code().unwrap_or(1) & 0xFF) as u8;
512    }
513  }
514
515  Ok(last_status)
516}
517
518#[derive(Debug, PartialEq, Clone)]
519pub struct Command {
520  pub name: String,
521  pub path: Option<String>,
522  pub args: Vec<String>,
523}
524
525impl Command {
526  pub fn new(name: &str, path: Option<String>, args: Vec<String>) -> Self {
527    Self {
528      name: name.to_owned(),
529      path,
530      args,
531    }
532  }
533
534  pub fn run(&self) -> anyhow::Result<()> {
535    let program = self.path.as_deref().unwrap_or(&self.name);
536    let mut child = std::process::Command::new(program)
537      .args(&self.args)
538      .spawn()
539      .with_context(|| format!("failed to spawn `{program}`"))?;
540    child
541      .wait()
542      .with_context(|| format!("failed to wait for `{program}`"))?;
543    Ok(())
544  }
545
546  pub fn run_with_stdio(&self, stdout: &StdoutTarget, stderr: &StderrTarget) -> anyhow::Result<u8> {
547    let program = self.path.as_deref().unwrap_or(&self.name);
548    let mut command = std::process::Command::new(program);
549    command.args(&self.args);
550
551    match stdout {
552      StdoutTarget::Stdout => {}
553      StdoutTarget::Overwrite(path) => {
554        let file = File::create(path)?;
555        command.stdout(Stdio::from(file));
556      }
557      StdoutTarget::Append(path) => {
558        let file = File::options().append(true).create(true).open(path)?;
559        command.stdout(Stdio::from(file));
560      }
561    }
562
563    match stderr {
564      StderrTarget::Stderr => {}
565      StderrTarget::Overwrite(path) => {
566        let file = File::create(path)?;
567        command.stderr(Stdio::from(file));
568      }
569      StderrTarget::Append(path) => {
570        let file = File::options().append(true).create(true).open(path)?;
571        command.stderr(Stdio::from(file));
572      }
573    }
574
575    let mut child = command
576      .spawn()
577      .with_context(|| format!("failed to spawn `{program}`"))?;
578    let status = child
579      .wait()
580      .with_context(|| format!("failed to wait for `{program}`"))?;
581    let code = status.code().unwrap_or(1);
582    Ok((code & 0xFF) as u8)
583  }
584}
585
586pub fn resolve_types(args: &[String]) -> String {
587  args
588    .iter()
589    .map(|name| {
590      if is_builtin(name) {
591        format!("{name} is a shell builtin")
592      } else {
593        match find_executable(name) {
594          Some(path) => format!("{name} is {}", path.display()),
595          None => format!("{name}: not found"),
596        }
597      }
598    })
599    .collect::<Vec<String>>()
600    .join("\n")
601}
602
603pub fn find_executable(cmd: &str) -> Option<PathBuf> {
604  find_executable_in_path(cmd)
605}
606
607/// Returns sorted, deduplicated command names (builtins + PATH executables) that start with `prefix`.
608/// Used for tab completion; only the first word of the line should be completed.
609pub fn complete_command(prefix: &str) -> Vec<String> {
610  let mut names: Vec<String> = BUILTIN_NAMES
611    .iter()
612    .filter(|n| n.starts_with(prefix))
613    .map(|s| (*s).to_string())
614    .collect();
615
616  let path_var = env::var("PATH").unwrap_or_default();
617  for dir in env::split_paths(&path_var) {
618    let Ok(entries) = fs::read_dir(&dir) else {
619      continue;
620    };
621    for entry in entries.flatten() {
622      let path = entry.path();
623      let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
624        continue;
625      };
626      if !name.starts_with(prefix) {
627        continue;
628      }
629      let meta = match entry.metadata() {
630        Ok(m) => m,
631        Err(_) => continue,
632      };
633      if meta.is_dir() {
634        continue;
635      }
636      #[cfg(unix)]
637      if meta.permissions().mode() & 0o111 == 0 {
638        continue;
639      }
640      names.push(name.to_string());
641    }
642  }
643
644  names.sort_unstable();
645  names.dedup();
646  names
647}
648
649pub fn change_dir(target: &str) -> anyhow::Result<()> {
650  let new_path = if target.is_empty() || target == "~" {
651    env::var("HOME").context("HOME not set")?
652  } else {
653    target.to_string()
654  };
655
656  let path = Path::new(&new_path);
657
658  if !path.exists() {
659    println!("cd: {target}: No such file or directory");
660    return Ok(());
661  }
662
663  env::set_current_dir(path).with_context(|| format!("cd: {target}"))?;
664
665  let updated_cwd = env::current_dir().context("get cwd after cd")?;
666  unsafe {
667    env::set_var("PWD", updated_cwd);
668  }
669
670  Ok(())
671}
672
673pub fn echo_args<W: Write>(args: &[String], out: &mut W) -> anyhow::Result<()> {
674  writeln!(out, "{}", args.join(" "))?;
675  Ok(())
676}
677
678#[cfg(test)]
679mod tests {
680  use super::*;
681  use std::fs;
682  use std::io::Read;
683  use std::path::PathBuf;
684
685  #[test]
686  fn from_input_empty_returns_none() {
687    assert!(matches!(Cmd::from_input("").unwrap(), None));
688    assert!(matches!(Cmd::from_input("   ").unwrap(), None));
689  }
690
691  #[test]
692  fn from_input_echo_preserves_whitespace() {
693    let cmd = Cmd::from_input(r#"echo "hello   world""#).unwrap().unwrap();
694    assert!(cmd.is_echo());
695    let Cmd::Echo { cmd, .. } = cmd else {
696      unreachable!()
697    };
698    assert_eq!(cmd.args, vec!["hello   world"]);
699  }
700
701  #[test]
702  fn from_input_echo_multiple_args() {
703    let cmd = Cmd::from_input("echo a b c").unwrap().unwrap();
704    let Cmd::Echo { cmd, .. } = cmd else {
705      unreachable!()
706    };
707    assert_eq!(cmd.args, vec!["a", "b", "c"]);
708  }
709
710  #[test]
711  fn needs_more_input_unclosed_quotes() {
712    assert!(needs_more_input(r#"echo "hello"#));
713    assert!(needs_more_input("echo 'hello"));
714    assert!(!needs_more_input(r#"echo "hello""#));
715    assert!(!needs_more_input(""));
716  }
717
718  #[test]
719  fn from_parts_exit_no_args_is_zero() {
720    let cmd = Cmd::from_parts("exit", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
721    assert!(matches!(cmd, Cmd::Exit(0)));
722  }
723
724  #[test]
725  fn from_parts_exit_with_code() {
726    let cmd = Cmd::from_parts(
727      "exit",
728      vec!["42".into()],
729      StdoutTarget::Stdout,
730      StderrTarget::Stderr,
731    );
732    assert!(matches!(cmd, Cmd::Exit(42)));
733  }
734
735  #[test]
736  fn from_parts_pwd() {
737    let cmd = Cmd::from_parts("pwd", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
738    assert!(matches!(cmd, Cmd::Pwd { .. }));
739  }
740
741  #[test]
742  fn from_parts_type_args() {
743    let cmd = Cmd::from_parts(
744      "type",
745      vec!["cd".into(), "ls".into()],
746      StdoutTarget::Stdout,
747      StderrTarget::Stderr,
748    );
749    let Cmd::Type { cmd, .. } = cmd else {
750      unreachable!()
751    };
752    assert_eq!(cmd.args, vec!["cd", "ls"]);
753  }
754
755  #[test]
756  fn from_parts_cd_args() {
757    let cmd = Cmd::from_parts(
758      "cd",
759      vec!["/tmp".into()],
760      StdoutTarget::Stdout,
761      StderrTarget::Stderr,
762    );
763    let Cmd::Cd { cmd, .. } = cmd else {
764      unreachable!()
765    };
766    assert_eq!(cmd.args, vec!["/tmp"]);
767  }
768
769  #[test]
770  fn is_builtin_known() {
771    for name in BUILTIN_NAMES {
772      assert!(is_builtin(name), "{name} should be builtin");
773    }
774  }
775
776  #[test]
777  fn is_builtin_unknown() {
778    assert!(!is_builtin("ls"));
779    assert!(!is_builtin(""));
780  }
781
782  #[test]
783  fn split_by_semicolon_respects_quotes() {
784    let input = r#"echo "a;b"; echo c; echo 'd;e'"#;
785    let parts = split_by_semicolon(input);
786    assert_eq!(parts.len(), 3);
787    assert_eq!(parts[0], r#"echo "a;b""#);
788    assert_eq!(parts[1], "echo c");
789    assert_eq!(parts[2], r#"echo 'd;e'"#);
790  }
791
792  #[test]
793  fn split_by_and_or_respects_quotes() {
794    let input = r#"echo "a && b" && echo c || echo 'd || e'"#;
795    let (parts, ops) = split_by_and_or(input);
796    assert_eq!(
797      parts,
798      vec![r#"echo "a && b""#, "echo c", r#"echo 'd || e'"#]
799    );
800    assert_eq!(ops, vec![ControlOp::AndAnd, ControlOp::OrOr]);
801  }
802
803  #[test]
804  fn run_one_part_uses_builtin_status() {
805    let (result, status) = run_one_part("echo ok", "ase-test", &[]).unwrap();
806    assert!(matches!(result, RunResult::Continue));
807    assert_eq!(status, 0);
808
809    let (result, status) = run_one_part("definitely-does-not-exist-xyz", "ase-test", &[]).unwrap();
810    assert!(matches!(result, RunResult::Continue));
811    assert_eq!(status, 127);
812  }
813
814  #[test]
815  fn run_one_part_pipeline_command_not_found_gives_127() {
816    let (result, status) =
817      run_one_part("no-such-cmd-abc | also-no-such-cmd-def", "ase-test", &[]).unwrap();
818    assert!(matches!(result, RunResult::Continue));
819    assert_eq!(status, 127);
820  }
821
822  fn tmp_path(name: &str) -> PathBuf {
823    let mut p = std::env::temp_dir();
824    p.push(format!("ase_test_{name}_{}", std::process::id()));
825    p
826  }
827
828  #[test]
829  fn control_and_and_runs_second_only_on_success() {
830    let path = tmp_path("and_and");
831    let line = format!(
832      "echo first > {} && echo second >> {}",
833      path.display(),
834      path.display()
835    );
836
837    let result = run_line(&line, "ase-test", &[]).unwrap();
838    assert!(matches!(result, RunResult::Continue));
839
840    let mut contents = String::new();
841    let mut file = fs::File::open(&path).unwrap();
842    file.read_to_string(&mut contents).unwrap();
843    fs::remove_file(&path).ok();
844
845    assert!(contents.contains("first"));
846    assert!(contents.contains("second"));
847  }
848
849  #[test]
850  fn control_and_and_skips_on_failure() {
851    let path = tmp_path("and_and_skip");
852    let line = format!(
853      "no-such-cmd-xyz && echo should-not-run > {}",
854      path.display()
855    );
856
857    let result = run_line(&line, "ase-test", &[]).unwrap();
858    assert!(matches!(result, RunResult::Continue));
859    assert!(!path.exists());
860  }
861
862  #[test]
863  fn control_or_or_runs_on_failure() {
864    let path = tmp_path("or_or");
865    let line = format!(
866      "no-such-cmd-xyz || echo ran-after-failure > {}",
867      path.display()
868    );
869
870    let result = run_line(&line, "ase-test", &[]).unwrap();
871    assert!(matches!(result, RunResult::Continue));
872
873    let contents = fs::read_to_string(&path).unwrap();
874    fs::remove_file(&path).ok();
875    assert!(contents.contains("ran-after-failure"));
876  }
877
878  #[test]
879  fn control_or_or_skips_on_success() {
880    let path = tmp_path("or_or_skip");
881    let line = format!("echo ok || echo should-not-run > {}", path.display());
882
883    let result = run_line(&line, "ase-test", &[]).unwrap();
884    assert!(matches!(result, RunResult::Continue));
885    assert!(!path.exists());
886  }
887
888  #[test]
889  fn semicolon_always_runs_both() {
890    let path = tmp_path("semicolon");
891    let line = format!(
892      "echo one > {}; echo two >> {}",
893      path.display(),
894      path.display()
895    );
896
897    let result = run_line(&line, "ase-test", &[]).unwrap();
898    assert!(matches!(result, RunResult::Continue));
899
900    let contents = fs::read_to_string(&path).unwrap();
901    fs::remove_file(&path).ok();
902    assert!(contents.contains("one"));
903    assert!(contents.contains("two"));
904  }
905
906  #[test]
907  fn history_builtin_respects_count_and_writes_to_file() {
908    let path = tmp_path("history");
909    let history = vec!["ls".to_string(), "echo a".to_string(), "echo b".to_string()];
910    let line = format!("history 2 > {}", path.display());
911
912    let result = run_line(&line, "ase-test", &history).unwrap();
913    assert!(matches!(result, RunResult::Continue));
914
915    let contents = fs::read_to_string(&path).unwrap();
916    fs::remove_file(&path).ok();
917
918    assert!(contents.contains("echo a"));
919    assert!(contents.contains("echo b"));
920    assert!(!contents.contains("ls"));
921  }
922
923  #[test]
924  fn complete_command_includes_builtins_and_path_executables() {
925    let names = complete_command("ec");
926    assert!(names.contains(&"echo".to_string()));
927  }
928}