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;5;208m"; // #fa912a (256-color for broad terminal support)
702const COLOR_HIDDEN: &str = "\x1b[38;5;245m"; // grey
703const COLOR_RESET: &str = "\x1b[0m";
704
705fn terminal_width() -> usize {
706  #[cfg(unix)]
707  {
708    unsafe {
709      let mut ws: libc::winsize = std::mem::zeroed();
710      if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 {
711        return ws.ws_col as usize;
712      }
713    }
714  }
715  80
716}
717
718fn display_name(name: &str, is_dir: bool) -> String {
719  if is_dir {
720    format!("{name}/")
721  } else {
722    name.to_string()
723  }
724}
725
726fn colorize_ls_entry(display: &str, is_dir: bool, is_hidden: bool) -> String {
727  if is_dir && is_hidden {
728    format!("{COLOR_HIDDEN}{display}{COLOR_RESET}")
729  } else if is_dir {
730    format!("{COLOR_DIR}{display}{COLOR_RESET}")
731  } else if is_hidden {
732    format!("{COLOR_HIDDEN}{display}{COLOR_RESET}")
733  } else {
734    display.to_string()
735  }
736}
737
738/// Print entries in a column grid that fills top-to-bottom, left-to-right (like system `ls`).
739fn print_columns<W: Write>(
740  out: &mut W,
741  entries: &[(String, bool)],
742  term_width: usize,
743) -> anyhow::Result<()> {
744  if entries.is_empty() {
745    return Ok(());
746  }
747
748  let displays: Vec<String> = entries
749    .iter()
750    .map(|(name, is_dir)| display_name(name, *is_dir))
751    .collect();
752
753  let col_gap = 14usize;
754  let count = displays.len();
755
756  // Try increasing number of columns until they no longer fit
757  let mut best_ncols = 1usize;
758  let mut best_col_widths: Vec<usize> = vec![0];
759
760  for ncols in 1..=count {
761    let nrows = (count + ncols - 1) / ncols;
762    let mut col_widths = vec![0usize; ncols];
763
764    for (i, d) in displays.iter().enumerate() {
765      let col = i / nrows;
766      col_widths[col] = col_widths[col].max(d.len());
767    }
768
769    let total: usize = col_widths.iter().sum::<usize>() + col_gap * ncols.saturating_sub(1);
770    if total <= term_width {
771      best_ncols = ncols;
772      best_col_widths = col_widths;
773    } else {
774      break;
775    }
776  }
777
778  let nrows = (count + best_ncols - 1) / best_ncols;
779
780  for row in 0..nrows {
781    let mut line = String::new();
782    for col in 0..best_ncols {
783      let idx = col * nrows + row;
784      if idx >= count {
785        break;
786      }
787      let (name, is_dir) = &entries[idx];
788      let d = &displays[idx];
789      let is_hidden = name.starts_with('.');
790      let colored = colorize_ls_entry(d, *is_dir, is_hidden);
791
792      if col + 1 < best_ncols && (col + 1) * nrows + row < count {
793        let pad = best_col_widths[col] - d.len() + col_gap;
794        line.push_str(&colored);
795        line.extend(std::iter::repeat(' ').take(pad));
796      } else {
797        line.push_str(&colored);
798      }
799    }
800    writeln!(out, "{line}")?;
801  }
802
803  Ok(())
804}
805
806fn run_ls(args: &[String], stdout_target: &StdoutTarget) -> anyhow::Result<()> {
807  let mut show_all = false;
808  let mut long_format = false;
809  let mut paths: Vec<String> = Vec::new();
810
811  for arg in args {
812    if arg.starts_with('-') && !arg.starts_with("--") {
813      for ch in arg[1..].chars() {
814        match ch {
815          'a' => show_all = true,
816          'l' => long_format = true,
817          _ => {}
818        }
819      }
820    } else {
821      paths.push(arg.clone());
822    }
823  }
824
825  if paths.is_empty() {
826    paths.push(".".to_string());
827  }
828
829  let multiple = paths.len() > 1;
830  let mut out = open_writer(stdout_target)?;
831  let tw = terminal_width();
832
833  for (i, path_str) in paths.iter().enumerate() {
834    let path = Path::new(path_str);
835    if !path.exists() {
836      writeln!(
837        out,
838        "ls: cannot access '{path_str}': No such file or directory"
839      )?;
840      continue;
841    }
842
843    if !path.is_dir() {
844      let name = path.file_name().unwrap_or_default().to_string_lossy();
845      writeln!(out, "{name}")?;
846      continue;
847    }
848
849    if multiple {
850      if i > 0 {
851        writeln!(out)?;
852      }
853      writeln!(out, "{path_str}:")?;
854    }
855
856    let mut entries: Vec<(String, bool)> = Vec::new();
857    if show_all {
858      entries.push((".".to_string(), true));
859      entries.push(("..".to_string(), true));
860    }
861    for entry in fs::read_dir(path)? {
862      let entry = entry?;
863      let name = entry.file_name().to_string_lossy().into_owned();
864      let is_hidden = name.starts_with('.');
865      if !show_all && is_hidden {
866        continue;
867      }
868      let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
869      entries.push((name, is_dir));
870    }
871    entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
872
873    if long_format {
874      for (name, is_dir) in &entries {
875        let is_hidden = name.starts_with('.');
876        let d = display_name(name, *is_dir);
877        let colored = colorize_ls_entry(&d, *is_dir, is_hidden);
878        writeln!(out, "{colored}")?;
879      }
880    } else {
881      print_columns(&mut out, &entries, tw)?;
882    }
883  }
884
885  Ok(())
886}
887
888pub fn echo_args<W: Write>(args: &[String], out: &mut W) -> anyhow::Result<()> {
889  writeln!(out, "{}", args.join(" "))?;
890  Ok(())
891}
892
893#[cfg(test)]
894mod tests {
895  use super::*;
896  use std::fs;
897  use std::io::Read;
898  use std::path::PathBuf;
899
900  #[test]
901  fn from_input_empty_returns_none() {
902    assert!(matches!(Cmd::from_input("").unwrap(), None));
903    assert!(matches!(Cmd::from_input("   ").unwrap(), None));
904  }
905
906  #[test]
907  fn from_input_echo_preserves_whitespace() {
908    let cmd = Cmd::from_input(r#"echo "hello   world""#).unwrap().unwrap();
909    assert!(cmd.is_echo());
910    let Cmd::Echo { cmd, .. } = cmd else {
911      unreachable!()
912    };
913    assert_eq!(cmd.args, vec!["hello   world"]);
914  }
915
916  #[test]
917  fn from_input_echo_multiple_args() {
918    let cmd = Cmd::from_input("echo a b c").unwrap().unwrap();
919    let Cmd::Echo { cmd, .. } = cmd else {
920      unreachable!()
921    };
922    assert_eq!(cmd.args, vec!["a", "b", "c"]);
923  }
924
925  #[test]
926  fn needs_more_input_unclosed_quotes() {
927    assert!(needs_more_input(r#"echo "hello"#));
928    assert!(needs_more_input("echo 'hello"));
929    assert!(!needs_more_input(r#"echo "hello""#));
930    assert!(!needs_more_input(""));
931  }
932
933  #[test]
934  fn from_parts_exit_no_args_is_zero() {
935    let cmd = Cmd::from_parts("exit", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
936    assert!(matches!(cmd, Cmd::Exit(0)));
937  }
938
939  #[test]
940  fn from_parts_exit_with_code() {
941    let cmd = Cmd::from_parts(
942      "exit",
943      vec!["42".into()],
944      StdoutTarget::Stdout,
945      StderrTarget::Stderr,
946    );
947    assert!(matches!(cmd, Cmd::Exit(42)));
948  }
949
950  #[test]
951  fn from_parts_pwd() {
952    let cmd = Cmd::from_parts("pwd", vec![], StdoutTarget::Stdout, StderrTarget::Stderr);
953    assert!(matches!(cmd, Cmd::Pwd { .. }));
954  }
955
956  #[test]
957  fn from_parts_type_args() {
958    let cmd = Cmd::from_parts(
959      "type",
960      vec!["cd".into(), "ls".into()],
961      StdoutTarget::Stdout,
962      StderrTarget::Stderr,
963    );
964    let Cmd::Type { cmd, .. } = cmd else {
965      unreachable!()
966    };
967    assert_eq!(cmd.args, vec!["cd", "ls"]);
968  }
969
970  #[test]
971  fn from_parts_cd_args() {
972    let cmd = Cmd::from_parts(
973      "cd",
974      vec!["/tmp".into()],
975      StdoutTarget::Stdout,
976      StderrTarget::Stderr,
977    );
978    let Cmd::Cd { cmd, .. } = cmd else {
979      unreachable!()
980    };
981    assert_eq!(cmd.args, vec!["/tmp"]);
982  }
983
984  #[test]
985  fn is_builtin_known() {
986    for name in BUILTIN_NAMES {
987      assert!(is_builtin(name), "{name} should be builtin");
988    }
989  }
990
991  #[test]
992  fn is_builtin_unknown() {
993    assert!(!is_builtin("ls"));
994    assert!(!is_builtin(""));
995  }
996
997  #[test]
998  fn split_by_semicolon_respects_quotes() {
999    let input = r#"echo "a;b"; echo c; echo 'd;e'"#;
1000    let parts = split_by_semicolon(input);
1001    assert_eq!(parts.len(), 3);
1002    assert_eq!(parts[0], r#"echo "a;b""#);
1003    assert_eq!(parts[1], "echo c");
1004    assert_eq!(parts[2], r#"echo 'd;e'"#);
1005  }
1006
1007  #[test]
1008  fn split_by_and_or_respects_quotes() {
1009    let input = r#"echo "a && b" && echo c || echo 'd || e'"#;
1010    let (parts, ops) = split_by_and_or(input);
1011    assert_eq!(
1012      parts,
1013      vec![r#"echo "a && b""#, "echo c", r#"echo 'd || e'"#]
1014    );
1015    assert_eq!(ops, vec![ControlOp::AndAnd, ControlOp::OrOr]);
1016  }
1017
1018  #[test]
1019  fn run_one_part_uses_builtin_status() {
1020    let (result, status) = run_one_part("echo ok", "ase-test", &[]).unwrap();
1021    assert!(matches!(result, RunResult::Continue));
1022    assert_eq!(status, 0);
1023
1024    let (result, status) = run_one_part("definitely-does-not-exist-xyz", "ase-test", &[]).unwrap();
1025    assert!(matches!(result, RunResult::Continue));
1026    assert_eq!(status, 127);
1027  }
1028
1029  #[test]
1030  fn run_one_part_pipeline_command_not_found_gives_127() {
1031    let (result, status) =
1032      run_one_part("no-such-cmd-abc | also-no-such-cmd-def", "ase-test", &[]).unwrap();
1033    assert!(matches!(result, RunResult::Continue));
1034    assert_eq!(status, 127);
1035  }
1036
1037  fn tmp_path(name: &str) -> PathBuf {
1038    let mut p = std::env::temp_dir();
1039    p.push(format!("ase_test_{name}_{}", std::process::id()));
1040    p
1041  }
1042
1043  #[test]
1044  fn control_and_and_runs_second_only_on_success() {
1045    let path = tmp_path("and_and");
1046    let line = format!(
1047      "echo first > {} && echo second >> {}",
1048      path.display(),
1049      path.display()
1050    );
1051
1052    let result = run_line(&line, "ase-test", &[]).unwrap();
1053    assert!(matches!(result, RunResult::Continue));
1054
1055    let mut contents = String::new();
1056    let mut file = fs::File::open(&path).unwrap();
1057    file.read_to_string(&mut contents).unwrap();
1058    fs::remove_file(&path).ok();
1059
1060    assert!(contents.contains("first"));
1061    assert!(contents.contains("second"));
1062  }
1063
1064  #[test]
1065  fn control_and_and_skips_on_failure() {
1066    let path = tmp_path("and_and_skip");
1067    let line = format!(
1068      "no-such-cmd-xyz && echo should-not-run > {}",
1069      path.display()
1070    );
1071
1072    let result = run_line(&line, "ase-test", &[]).unwrap();
1073    assert!(matches!(result, RunResult::Continue));
1074    assert!(!path.exists());
1075  }
1076
1077  #[test]
1078  fn control_or_or_runs_on_failure() {
1079    let path = tmp_path("or_or");
1080    let line = format!(
1081      "no-such-cmd-xyz || echo ran-after-failure > {}",
1082      path.display()
1083    );
1084
1085    let result = run_line(&line, "ase-test", &[]).unwrap();
1086    assert!(matches!(result, RunResult::Continue));
1087
1088    let contents = fs::read_to_string(&path).unwrap();
1089    fs::remove_file(&path).ok();
1090    assert!(contents.contains("ran-after-failure"));
1091  }
1092
1093  #[test]
1094  fn control_or_or_skips_on_success() {
1095    let path = tmp_path("or_or_skip");
1096    let line = format!("echo ok || echo should-not-run > {}", path.display());
1097
1098    let result = run_line(&line, "ase-test", &[]).unwrap();
1099    assert!(matches!(result, RunResult::Continue));
1100    assert!(!path.exists());
1101  }
1102
1103  #[test]
1104  fn semicolon_always_runs_both() {
1105    let path = tmp_path("semicolon");
1106    let line = format!(
1107      "echo one > {}; echo two >> {}",
1108      path.display(),
1109      path.display()
1110    );
1111
1112    let result = run_line(&line, "ase-test", &[]).unwrap();
1113    assert!(matches!(result, RunResult::Continue));
1114
1115    let contents = fs::read_to_string(&path).unwrap();
1116    fs::remove_file(&path).ok();
1117    assert!(contents.contains("one"));
1118    assert!(contents.contains("two"));
1119  }
1120
1121  #[test]
1122  fn history_builtin_respects_count_and_writes_to_file() {
1123    let path = tmp_path("history");
1124    let history = vec!["ls".to_string(), "echo a".to_string(), "echo b".to_string()];
1125    let line = format!("history 2 > {}", path.display());
1126
1127    let result = run_line(&line, "ase-test", &history).unwrap();
1128    assert!(matches!(result, RunResult::Continue));
1129
1130    let contents = fs::read_to_string(&path).unwrap();
1131    fs::remove_file(&path).ok();
1132
1133    assert!(contents.contains("echo a"));
1134    assert!(contents.contains("echo b"));
1135    assert!(!contents.contains("ls"));
1136  }
1137
1138  #[test]
1139  fn complete_command_includes_builtins_and_path_executables() {
1140    let names = complete_command("ec");
1141    assert!(names.contains(&"echo".to_string()));
1142  }
1143}