1use 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
42fn 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
67fn 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
102fn 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 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 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 let op = if idx == 0 { None } else { ops.get(idx - 1) };
157 let should_run = match op {
158 None => true, 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; }
166 if op == Some(&ControlOp::OrOr) {
167 break; }
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 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
635pub 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"; const COLOR_HIDDEN: &str = "\x1b[38;5;245m"; const 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
738fn 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 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}