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", "ls"];
31
32pub fn 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  Ls {
216    cmd: Command,
217    stdout: StdoutTarget,
218    stderr: StderrTarget,
219  },
220  Unknown {
221    cmd: Command,
222    stderr: StderrTarget,
223  },
224}
225
226impl Cmd {
227  pub fn from_input(raw: &str) -> anyhow::Result<Option<Self>> {
228    let raw = raw.trim();
229    if raw.is_empty() {
230      return Ok(None);
231    }
232    let tokens = shlex::split(raw).unwrap_or_default();
233    if tokens.is_empty() {
234      return Ok(None);
235    }
236    let Some(inv) = ParsedInvocation::from_tokens(tokens) else {
237      return Ok(None);
238    };
239    Ok(Some(Self::from_parts(
240      &inv.cmd_name,
241      inv.args,
242      inv.stdout,
243      inv.stderr,
244    )))
245  }
246
247  pub fn from_parts(
248    cmd_name: &str,
249    args: Vec<String>,
250    stdout: StdoutTarget,
251    stderr: StderrTarget,
252  ) -> Self {
253    match cmd_name {
254      "cd" => Cmd::Cd {
255        cmd: Command::new(cmd_name, None, args),
256        stderr,
257      },
258      "exit" => {
259        let code = args.first().and_then(|s| s.parse::<u8>().ok()).unwrap_or(0);
260        Cmd::Exit(code)
261      }
262      "echo" => Cmd::Echo {
263        cmd: Command::new(cmd_name, None, args),
264        stdout,
265        stderr,
266      },
267      "history" => Cmd::History {
268        cmd: Command::new(cmd_name, None, args),
269        stdout,
270        stderr,
271      },
272      "type" => Cmd::Type {
273        cmd: Command::new(cmd_name, None, args),
274        stdout,
275        stderr,
276      },
277      "pwd" => Cmd::Pwd { stdout, stderr },
278      "ls" => Cmd::Ls {
279        cmd: Command::new(cmd_name, None, args),
280        stdout,
281        stderr,
282      },
283      _ => {
284        if cmd_name.contains('/') {
285          Cmd::Exec {
286            cmd: Command::new(cmd_name, Some(cmd_name.to_string()), args),
287            stdout,
288            stderr,
289          }
290        } else if let Some(path_buf) = find_executable(cmd_name) {
291          let path_str = path_buf
292            .into_os_string()
293            .into_string()
294            .unwrap_or_else(|_| String::new());
295          Cmd::Exec {
296            cmd: Command::new(cmd_name, Some(path_str), args),
297            stdout,
298            stderr,
299          }
300        } else {
301          Cmd::Unknown {
302            cmd: Command::new(cmd_name, None, args),
303            stderr,
304          }
305        }
306      }
307    }
308  }
309
310  /// Run the command and return (RunResult, exit_status). Exit status is used for `&&` / `||`.
311  pub fn run_with_status(
312    &self,
313    shell_name: &str,
314    history: &[String],
315  ) -> anyhow::Result<(RunResult, u8)> {
316    match self {
317      Cmd::Echo {
318        cmd,
319        stdout,
320        stderr: _,
321      } => {
322        let mut out = open_writer(stdout)?;
323        echo_args(&cmd.args, &mut out)?;
324        Ok((RunResult::Continue, 0))
325      }
326      Cmd::Exit(code) => Ok((RunResult::Exit(*code), *code)),
327      Cmd::Type {
328        cmd,
329        stdout,
330        stderr: _,
331      } => {
332        let mut out = open_writer(stdout)?;
333        writeln!(out, "{}", resolve_types(&cmd.args))?;
334        Ok((RunResult::Continue, 0))
335      }
336      Cmd::Exec {
337        cmd,
338        stdout,
339        stderr,
340      } => {
341        let status = match cmd.run_with_stdio(stdout, stderr) {
342          Ok(s) => s,
343          Err(err) => {
344            let mut err_out = open_stderr_writer(stderr)?;
345            writeln!(err_out, "{shell_name}: {err}")?;
346            127
347          }
348        };
349        Ok((RunResult::Continue, status))
350      }
351      Cmd::Cd { cmd, stderr } => {
352        let target = cmd.args.first().map(String::as_str).unwrap_or("");
353        let status = match change_dir(target) {
354          Ok(()) => 0,
355          Err(err) => {
356            let mut err_out = open_stderr_writer(stderr)?;
357            writeln!(err_out, "{shell_name}: {err}")?;
358            1
359          }
360        };
361        Ok((RunResult::Continue, status))
362      }
363      Cmd::Pwd { stdout, stderr: _ } => {
364        let mut out = open_writer(stdout)?;
365        let dir = env::current_dir().context("get current directory")?;
366        writeln!(out, "{}", dir.display())?;
367        Ok((RunResult::Continue, 0))
368      }
369      Cmd::History {
370        cmd,
371        stdout,
372        stderr: _,
373      } => {
374        let count = cmd.args.get(0).and_then(|s| s.parse::<usize>().ok());
375        let total = history.len();
376        let start = match count {
377          Some(n) if n < total => total - n,
378          Some(_) => 0,
379          None => 0,
380        };
381
382        let mut out = open_writer(stdout)?;
383        for (idx, entry) in history.iter().enumerate().skip(start) {
384          writeln!(out, "  {:>4}  {entry}", idx + 1)?;
385        }
386        Ok((RunResult::Continue, 0))
387      }
388      Cmd::Ls {
389        cmd,
390        stdout,
391        stderr,
392      } => {
393        let status = match run_ls(&cmd.args, stdout) {
394          Ok(()) => 0,
395          Err(err) => {
396            let mut err_out = open_stderr_writer(stderr)?;
397            writeln!(err_out, "{shell_name}: ls: {err}")?;
398            1
399          }
400        };
401        Ok((RunResult::Continue, status))
402      }
403      Cmd::Unknown { cmd, stderr } => {
404        let mut err_out = open_stderr_writer(stderr)?;
405        writeln!(err_out, "{shell_name}: command not found: {}", cmd.name)?;
406        Ok((RunResult::Continue, 127))
407      }
408    }
409  }
410
411  pub fn run(&self, shell_name: &str, history: &[String]) -> anyhow::Result<RunResult> {
412    self.run_with_status(shell_name, history).map(|(r, _)| r)
413  }
414}
415
416fn split_pipeline(tokens: Vec<String>) -> Option<Vec<Vec<String>>> {
417  let mut segments = Vec::new();
418  let mut current = Vec::new();
419
420  for tok in tokens {
421    if tok == "|" {
422      if current.is_empty() {
423        return None;
424      }
425      segments.push(std::mem::take(&mut current));
426    } else {
427      current.push(tok);
428    }
429  }
430
431  if current.is_empty() {
432    return None;
433  }
434
435  segments.push(current);
436  Some(segments)
437}
438
439fn run_pipeline_for_status(tokens: Vec<String>, shell_name: &str) -> anyhow::Result<u8> {
440  use anyhow::anyhow;
441
442  let segments = match split_pipeline(tokens) {
443    Some(segs) => segs,
444    None => {
445      eprintln!("{shell_name}: invalid pipeline");
446      return Ok(127);
447    }
448  };
449
450  let mut invocations = Vec::new();
451  for seg in segments {
452    let Some(inv) = ParsedInvocation::from_tokens(seg) else {
453      eprintln!("{shell_name}: invalid command in pipeline");
454      return Ok(127);
455    };
456    invocations.push(inv);
457  }
458
459  if invocations.is_empty() {
460    return Ok(127);
461  }
462
463  let mut children = Vec::new();
464  let mut prev_stdout: Option<ChildStdout> = None;
465
466  for (idx, inv) in invocations.iter().enumerate() {
467    let is_last = idx == invocations.len() - 1;
468
469    let program_path = if inv.cmd_name.contains('/') {
470      PathBuf::from(&inv.cmd_name)
471    } else if let Some(p) = find_executable(&inv.cmd_name) {
472      p
473    } else {
474      eprintln!("{shell_name}: command not found: {}", inv.cmd_name);
475      return Ok(127);
476    };
477
478    let mut cmd = OsCommand::new(&program_path);
479    cmd.args(&inv.args);
480
481    if let Some(stdin) = prev_stdout.take() {
482      cmd.stdin(Stdio::from(stdin));
483    }
484
485    if is_last {
486      match &inv.stdout {
487        StdoutTarget::Stdout => {}
488        StdoutTarget::Overwrite(path) => {
489          let file = File::create(path)?;
490          cmd.stdout(Stdio::from(file));
491        }
492        StdoutTarget::Append(path) => {
493          let file = File::options().append(true).create(true).open(path)?;
494          cmd.stdout(Stdio::from(file));
495        }
496      }
497    } else {
498      cmd.stdout(Stdio::piped());
499    }
500
501    if is_last {
502      match &inv.stderr {
503        StderrTarget::Stderr => {}
504        StderrTarget::Overwrite(path) => {
505          let file = File::create(path)?;
506          cmd.stderr(Stdio::from(file));
507        }
508        StderrTarget::Append(path) => {
509          let file = File::options().append(true).create(true).open(path)?;
510          cmd.stderr(Stdio::from(file));
511        }
512      }
513    }
514
515    let mut child = cmd
516      .spawn()
517      .with_context(|| format!("failed to spawn `{}`", inv.cmd_name))?;
518
519    if !is_last {
520      let child_stdout = child
521        .stdout
522        .take()
523        .ok_or_else(|| anyhow!("failed to capture stdout for pipeline stage"))?;
524      prev_stdout = Some(child_stdout);
525    }
526
527    children.push(child);
528  }
529
530  let mut last_status = 127u8;
531  for (i, mut child) in children.into_iter().enumerate() {
532    let exit_status = child
533      .wait()
534      .with_context(|| "failed to wait for pipeline stage")?;
535    if i == invocations.len() - 1 {
536      last_status = (exit_status.code().unwrap_or(1) & 0xFF) as u8;
537    }
538  }
539
540  Ok(last_status)
541}
542
543#[derive(Debug, PartialEq, Clone)]
544pub struct Command {
545  pub name: String,
546  pub path: Option<String>,
547  pub args: Vec<String>,
548}
549
550impl Command {
551  pub fn new(name: &str, path: Option<String>, args: Vec<String>) -> Self {
552    Self {
553      name: name.to_owned(),
554      path,
555      args,
556    }
557  }
558
559  pub fn run(&self) -> anyhow::Result<()> {
560    let program = self.path.as_deref().unwrap_or(&self.name);
561    let mut child = std::process::Command::new(program)
562      .args(&self.args)
563      .spawn()
564      .with_context(|| format!("failed to spawn `{program}`"))?;
565    child
566      .wait()
567      .with_context(|| format!("failed to wait for `{program}`"))?;
568    Ok(())
569  }
570
571  pub fn run_with_stdio(&self, stdout: &StdoutTarget, stderr: &StderrTarget) -> anyhow::Result<u8> {
572    let program = self.path.as_deref().unwrap_or(&self.name);
573    let mut command = std::process::Command::new(program);
574    command.args(&self.args);
575
576    match stdout {
577      StdoutTarget::Stdout => {}
578      StdoutTarget::Overwrite(path) => {
579        let file = File::create(path)?;
580        command.stdout(Stdio::from(file));
581      }
582      StdoutTarget::Append(path) => {
583        let file = File::options().append(true).create(true).open(path)?;
584        command.stdout(Stdio::from(file));
585      }
586    }
587
588    match stderr {
589      StderrTarget::Stderr => {}
590      StderrTarget::Overwrite(path) => {
591        let file = File::create(path)?;
592        command.stderr(Stdio::from(file));
593      }
594      StderrTarget::Append(path) => {
595        let file = File::options().append(true).create(true).open(path)?;
596        command.stderr(Stdio::from(file));
597      }
598    }
599
600    let mut child = command
601      .spawn()
602      .with_context(|| format!("failed to spawn `{program}`"))?;
603    let status = child
604      .wait()
605      .with_context(|| format!("failed to wait for `{program}`"))?;
606    let code = status.code().unwrap_or(1);
607    Ok((code & 0xFF) as u8)
608  }
609}
610
611pub fn resolve_types(args: &[String]) -> String {
612  args
613    .iter()
614    .map(|name| {
615      if is_builtin(name) {
616        format!("{name} is a shell builtin")
617      } else {
618        match find_executable(name) {
619          Some(path) => format!("{name} is {}", path.display()),
620          None => format!("{name}: not found"),
621        }
622      }
623    })
624    .collect::<Vec<String>>()
625    .join("\n")
626}
627
628pub fn find_executable(cmd: &str) -> Option<PathBuf> {
629  if cmd.is_empty() {
630    return None;
631  }
632  find_executable_in_path(cmd)
633}
634
635/// Returns sorted, deduplicated command names (builtins + PATH executables) that start with `prefix`.
636/// Used for tab completion; only the first word of the line should be completed.
637pub fn complete_command(prefix: &str) -> Vec<String> {
638  let mut names: Vec<String> = BUILTIN_NAMES
639    .iter()
640    .filter(|n| n.starts_with(prefix))
641    .map(|s| (*s).to_string())
642    .collect();
643
644  let path_var = env::var("PATH").unwrap_or_default();
645  for dir in env::split_paths(&path_var) {
646    let Ok(entries) = fs::read_dir(&dir) else {
647      continue;
648    };
649    for entry in entries.flatten() {
650      let path = entry.path();
651      let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
652        continue;
653      };
654      if !name.starts_with(prefix) {
655        continue;
656      }
657      let meta = match entry.metadata() {
658        Ok(m) => m,
659        Err(_) => continue,
660      };
661      if meta.is_dir() {
662        continue;
663      }
664      #[cfg(unix)]
665      if meta.permissions().mode() & 0o111 == 0 {
666        continue;
667      }
668      names.push(name.to_string());
669    }
670  }
671
672  names.sort_unstable();
673  names.dedup();
674  names
675}
676
677pub fn change_dir(target: &str) -> anyhow::Result<()> {
678  let new_path = if target.is_empty() || target == "~" {
679    env::var("HOME").context("HOME not set")?
680  } else {
681    target.to_string()
682  };
683
684  let path = Path::new(&new_path);
685
686  if !path.exists() {
687    println!("cd: {target}: No such file or directory");
688    return Ok(());
689  }
690
691  env::set_current_dir(path).with_context(|| format!("cd: {target}"))?;
692
693  let updated_cwd = env::current_dir().context("get cwd after cd")?;
694  unsafe {
695    env::set_var("PWD", updated_cwd);
696  }
697
698  Ok(())
699}
700
701const COLOR_DIR: &str = "\x1b[38;2;250;145;42m";   // #fa912a
702const COLOR_HIDDEN: &str = "\x1b[38;5;245m";       // grey
703const COLOR_RESET: &str = "\x1b[0m";
704
705fn run_ls(args: &[String], stdout_target: &StdoutTarget) -> anyhow::Result<()> {
706  let mut show_all = false;
707  let mut long_format = false;
708  let mut paths: Vec<String> = Vec::new();
709
710  for arg in args {
711    if arg.starts_with('-') && !arg.starts_with("--") {
712      for ch in arg[1..].chars() {
713        match ch {
714          'a' => show_all = true,
715          'l' => long_format = true,
716          _ => {}
717        }
718      }
719    } else {
720      paths.push(arg.clone());
721    }
722  }
723
724  if paths.is_empty() {
725    paths.push(".".to_string());
726  }
727
728  let multiple = paths.len() > 1;
729  let mut out = open_writer(stdout_target)?;
730
731  for (i, path_str) in paths.iter().enumerate() {
732    let path = Path::new(path_str);
733    if !path.exists() {
734      writeln!(out, "ls: cannot access '{path_str}': No such file or directory")?;
735      continue;
736    }
737
738    if !path.is_dir() {
739      let name = path.file_name().unwrap_or_default().to_string_lossy();
740      writeln!(out, "{name}")?;
741      continue;
742    }
743
744    if multiple {
745      if i > 0 {
746        writeln!(out)?;
747      }
748      writeln!(out, "{path_str}:")?;
749    }
750
751    let mut entries: Vec<(String, bool)> = Vec::new();
752    for entry in fs::read_dir(path)? {
753      let entry = entry?;
754      let name = entry.file_name().to_string_lossy().into_owned();
755      let is_hidden = name.starts_with('.');
756      if !show_all && is_hidden {
757        continue;
758      }
759      let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
760      entries.push((name, is_dir));
761    }
762    entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
763
764    if long_format {
765      for (name, is_dir) in &entries {
766        let is_hidden = name.starts_with('.');
767        let colored = colorize_ls_entry(name, *is_dir, is_hidden);
768        writeln!(out, "{colored}")?;
769      }
770    } else {
771      let colored: Vec<String> = entries
772        .iter()
773        .map(|(name, is_dir)| {
774          let is_hidden = name.starts_with('.');
775          colorize_ls_entry(name, *is_dir, is_hidden)
776        })
777        .collect();
778      writeln!(out, "{}", colored.join("  "))?;
779    }
780  }
781
782  Ok(())
783}
784
785fn colorize_ls_entry(name: &str, is_dir: bool, is_hidden: bool) -> String {
786  if is_dir && is_hidden {
787    format!("{COLOR_HIDDEN}{name}/{COLOR_RESET}")
788  } else if is_dir {
789    format!("{COLOR_DIR}{name}/{COLOR_RESET}")
790  } else if is_hidden {
791    format!("{COLOR_HIDDEN}{name}{COLOR_RESET}")
792  } else {
793    name.to_string()
794  }
795}
796
797pub fn echo_args<W: Write>(args: &[String], out: &mut W) -> anyhow::Result<()> {
798  writeln!(out, "{}", args.join(" "))?;
799  Ok(())
800}
801
802#[cfg(test)]
803mod tests {
804  use super::*;
805  use std::fs;
806  use std::io::Read;
807  use std::path::PathBuf;
808
809  #[test]
810  fn from_input_empty_returns_none() {
811    assert!(matches!(Cmd::from_input("").unwrap(), None));
812    assert!(matches!(Cmd::from_input("   ").unwrap(), None));
813  }
814
815  #[test]
816  fn from_input_echo_preserves_whitespace() {
817    let cmd = Cmd::from_input(r#"echo "hello   world""#).unwrap().unwrap();
818    assert!(cmd.is_echo());
819    let Cmd::Echo { cmd, .. } = cmd else {
820      unreachable!()
821    };
822    assert_eq!(cmd.args, vec!["hello   world"]);
823  }
824
825  #[test]
826  fn from_input_echo_multiple_args() {
827    let cmd = Cmd::from_input("echo a b c").unwrap().unwrap();
828    let Cmd::Echo { cmd, .. } = cmd else {
829      unreachable!()
830    };
831    assert_eq!(cmd.args, vec!["a", "b", "c"]);
832  }
833
834  #[test]
835  fn needs_more_input_unclosed_quotes() {
836    assert!(needs_more_input(r#"echo "hello"#));
837    assert!(needs_more_input("echo 'hello"));
838    assert!(!needs_more_input(r#"echo "hello""#));
839    assert!(!needs_more_input(""));
840  }
841
842  #[test]
843  fn from_parts_exit_no_args_is_zero() {
844    let cmd = Cmd::from_parts("exit", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
845    assert!(matches!(cmd, Cmd::Exit(0)));
846  }
847
848  #[test]
849  fn from_parts_exit_with_code() {
850    let cmd = Cmd::from_parts(
851      "exit",
852      vec!["42".into()],
853      StdoutTarget::Stdout,
854      StderrTarget::Stderr,
855    );
856    assert!(matches!(cmd, Cmd::Exit(42)));
857  }
858
859  #[test]
860  fn from_parts_pwd() {
861    let cmd = Cmd::from_parts("pwd", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
862    assert!(matches!(cmd, Cmd::Pwd { .. }));
863  }
864
865  #[test]
866  fn from_parts_type_args() {
867    let cmd = Cmd::from_parts(
868      "type",
869      vec!["cd".into(), "ls".into()],
870      StdoutTarget::Stdout,
871      StderrTarget::Stderr,
872    );
873    let Cmd::Type { cmd, .. } = cmd else {
874      unreachable!()
875    };
876    assert_eq!(cmd.args, vec!["cd", "ls"]);
877  }
878
879  #[test]
880  fn from_parts_cd_args() {
881    let cmd = Cmd::from_parts(
882      "cd",
883      vec!["/tmp".into()],
884      StdoutTarget::Stdout,
885      StderrTarget::Stderr,
886    );
887    let Cmd::Cd { cmd, .. } = cmd else {
888      unreachable!()
889    };
890    assert_eq!(cmd.args, vec!["/tmp"]);
891  }
892
893  #[test]
894  fn is_builtin_known() {
895    for name in BUILTIN_NAMES {
896      assert!(is_builtin(name), "{name} should be builtin");
897    }
898  }
899
900  #[test]
901  fn is_builtin_unknown() {
902    assert!(!is_builtin("ls"));
903    assert!(!is_builtin(""));
904  }
905
906  #[test]
907  fn split_by_semicolon_respects_quotes() {
908    let input = r#"echo "a;b"; echo c; echo 'd;e'"#;
909    let parts = split_by_semicolon(input);
910    assert_eq!(parts.len(), 3);
911    assert_eq!(parts[0], r#"echo "a;b""#);
912    assert_eq!(parts[1], "echo c");
913    assert_eq!(parts[2], r#"echo 'd;e'"#);
914  }
915
916  #[test]
917  fn split_by_and_or_respects_quotes() {
918    let input = r#"echo "a && b" && echo c || echo 'd || e'"#;
919    let (parts, ops) = split_by_and_or(input);
920    assert_eq!(
921      parts,
922      vec![r#"echo "a && b""#, "echo c", r#"echo 'd || e'"#]
923    );
924    assert_eq!(ops, vec![ControlOp::AndAnd, ControlOp::OrOr]);
925  }
926
927  #[test]
928  fn run_one_part_uses_builtin_status() {
929    let (result, status) = run_one_part("echo ok", "ase-test", &[]).unwrap();
930    assert!(matches!(result, RunResult::Continue));
931    assert_eq!(status, 0);
932
933    let (result, status) = run_one_part("definitely-does-not-exist-xyz", "ase-test", &[]).unwrap();
934    assert!(matches!(result, RunResult::Continue));
935    assert_eq!(status, 127);
936  }
937
938  #[test]
939  fn run_one_part_pipeline_command_not_found_gives_127() {
940    let (result, status) =
941      run_one_part("no-such-cmd-abc | also-no-such-cmd-def", "ase-test", &[]).unwrap();
942    assert!(matches!(result, RunResult::Continue));
943    assert_eq!(status, 127);
944  }
945
946  fn tmp_path(name: &str) -> PathBuf {
947    let mut p = std::env::temp_dir();
948    p.push(format!("ase_test_{name}_{}", std::process::id()));
949    p
950  }
951
952  #[test]
953  fn control_and_and_runs_second_only_on_success() {
954    let path = tmp_path("and_and");
955    let line = format!(
956      "echo first > {} && echo second >> {}",
957      path.display(),
958      path.display()
959    );
960
961    let result = run_line(&line, "ase-test", &[]).unwrap();
962    assert!(matches!(result, RunResult::Continue));
963
964    let mut contents = String::new();
965    let mut file = fs::File::open(&path).unwrap();
966    file.read_to_string(&mut contents).unwrap();
967    fs::remove_file(&path).ok();
968
969    assert!(contents.contains("first"));
970    assert!(contents.contains("second"));
971  }
972
973  #[test]
974  fn control_and_and_skips_on_failure() {
975    let path = tmp_path("and_and_skip");
976    let line = format!(
977      "no-such-cmd-xyz && echo should-not-run > {}",
978      path.display()
979    );
980
981    let result = run_line(&line, "ase-test", &[]).unwrap();
982    assert!(matches!(result, RunResult::Continue));
983    assert!(!path.exists());
984  }
985
986  #[test]
987  fn control_or_or_runs_on_failure() {
988    let path = tmp_path("or_or");
989    let line = format!(
990      "no-such-cmd-xyz || echo ran-after-failure > {}",
991      path.display()
992    );
993
994    let result = run_line(&line, "ase-test", &[]).unwrap();
995    assert!(matches!(result, RunResult::Continue));
996
997    let contents = fs::read_to_string(&path).unwrap();
998    fs::remove_file(&path).ok();
999    assert!(contents.contains("ran-after-failure"));
1000  }
1001
1002  #[test]
1003  fn control_or_or_skips_on_success() {
1004    let path = tmp_path("or_or_skip");
1005    let line = format!("echo ok || echo should-not-run > {}", path.display());
1006
1007    let result = run_line(&line, "ase-test", &[]).unwrap();
1008    assert!(matches!(result, RunResult::Continue));
1009    assert!(!path.exists());
1010  }
1011
1012  #[test]
1013  fn semicolon_always_runs_both() {
1014    let path = tmp_path("semicolon");
1015    let line = format!(
1016      "echo one > {}; echo two >> {}",
1017      path.display(),
1018      path.display()
1019    );
1020
1021    let result = run_line(&line, "ase-test", &[]).unwrap();
1022    assert!(matches!(result, RunResult::Continue));
1023
1024    let contents = fs::read_to_string(&path).unwrap();
1025    fs::remove_file(&path).ok();
1026    assert!(contents.contains("one"));
1027    assert!(contents.contains("two"));
1028  }
1029
1030  #[test]
1031  fn history_builtin_respects_count_and_writes_to_file() {
1032    let path = tmp_path("history");
1033    let history = vec!["ls".to_string(), "echo a".to_string(), "echo b".to_string()];
1034    let line = format!("history 2 > {}", path.display());
1035
1036    let result = run_line(&line, "ase-test", &history).unwrap();
1037    assert!(matches!(result, RunResult::Continue));
1038
1039    let contents = fs::read_to_string(&path).unwrap();
1040    fs::remove_file(&path).ok();
1041
1042    assert!(contents.contains("echo a"));
1043    assert!(contents.contains("echo b"));
1044    assert!(!contents.contains("ls"));
1045  }
1046
1047  #[test]
1048  fn complete_command_includes_builtins_and_path_executables() {
1049    let names = complete_command("ec");
1050    assert!(names.contains(&"echo".to_string()));
1051  }
1052}