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;2;250;145;42m"; const COLOR_HIDDEN: &str = "\x1b[38;5;245m"; const 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}