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