gshell 1.0.3

gshell is a shell for people who live in the terminal. It pairs familiar Unix behavior with a tighter core, fast interaction, and an interface built to stay out of the way.
Documentation
use std::fs;

use gshell::{
    parser::Parser,
    runtime::{BootstrapExecutor, Executor},
    shell::{ExitCode, ShellAction, ShellState},
};

#[tokio::test]
async fn output_truncate_redirection_works() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let out = dir.path().join("out.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse(&format!("echo hello > {}", out.display()))
        .expect("parse should succeed");

    let result = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    match result {
        ShellAction::Continue(output) => {
            assert_eq!(output.exit_code, ExitCode::SUCCESS);
            assert!(output.stdout.is_empty());
        }
        ShellAction::Exit(_) => panic!("echo should not exit"),
    }

    let content = fs::read_to_string(out).expect("output file should be readable");
    assert_eq!(content, "hello\n");
}

#[tokio::test]
async fn output_append_redirection_works() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let out = dir.path().join("out.txt");
    fs::write(&out, "first\n").expect("seed output file should be writable");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse(&format!("echo second >> {}", out.display()))
        .expect("parse should succeed");

    let _ = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    let content = fs::read_to_string(out).expect("output file should be readable");
    assert_eq!(content, "first\nsecond\n");
}

#[tokio::test]
async fn output_append_redirection_uses_shell_working_directory() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let nested = dir.path().join("nested");
    fs::create_dir(&nested).expect("nested dir should be created");
    let out = nested.join("out.txt");
    fs::write(&out, "first\n").expect("seed output file should be writable");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");
    state.write().await.set_cwd(nested.clone());

    let parsed = parser
        .parse("echo second >> out.txt")
        .expect("parse should succeed");

    let _ = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    let content = fs::read_to_string(&out).expect("output file should be readable");
    assert_eq!(content, "first\nsecond\n");
}

#[tokio::test]
async fn stderr_truncate_redirection_works() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let err = dir.path().join("err.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse(&format!("type nope 2> {}", err.display()))
        .expect("parse should succeed");

    let result = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    match result {
        ShellAction::Continue(output) => {
            assert!(output.stderr.is_empty());
            assert_eq!(output.exit_code, ExitCode::FAILURE);
        }
        ShellAction::Exit(_) => panic!("type should not exit"),
    }

    let content = fs::read_to_string(err).expect("stderr file should be readable");
    assert!(content.contains("not found"));
}

#[tokio::test]
async fn stderr_append_redirection_works() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let err = dir.path().join("err.txt");
    fs::write(&err, "before\n").expect("seed stderr file should be writable");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse(&format!("type nope 2>> {}", err.display()))
        .expect("parse should succeed");

    let _ = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    let content = fs::read_to_string(err).expect("stderr file should be readable");
    assert!(content.starts_with("before\n"));
    assert!(content.contains("not found"));
}

#[tokio::test]
async fn redirected_builtin_does_not_write_to_terminal_output_buffer() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let out = dir.path().join("out.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse(&format!("pwd > {}", out.display()))
        .expect("parse should succeed");

    let result = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    match result {
        ShellAction::Continue(output) => {
            assert!(output.stdout.is_empty());
            assert_eq!(output.exit_code, ExitCode::SUCCESS);
        }
        ShellAction::Exit(_) => panic!("pwd should not exit"),
    }

    let content = fs::read_to_string(out).expect("output file should be readable");
    assert!(!content.trim().is_empty());
}

#[tokio::test]
async fn output_redirection_target_expands_environment_variables() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let out = dir.path().join("expanded-out.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");
    state
        .write()
        .await
        .set_env_var("OUTFILE", out.display().to_string());

    let parsed = parser
        .parse("echo hello > \"$OUTFILE\"")
        .expect("parse should succeed");

    let result = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    match result {
        ShellAction::Continue(output) => {
            assert_eq!(output.exit_code, ExitCode::SUCCESS);
            assert!(output.stdout.is_empty());
        }
        ShellAction::Exit(_) => panic!("echo should not exit"),
    }

    let content = fs::read_to_string(out).expect("output file should be readable");
    assert_eq!(content, "hello\n");
}

#[tokio::test]
async fn input_redirection_target_expands_environment_variables() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let input = dir.path().join("expanded-in.txt");
    let output = dir.path().join("captured-out.txt");
    fs::write(&input, "hello from file\n").expect("input file should be writable");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");
    state
        .write()
        .await
        .set_env_var("INFILE", input.display().to_string());
    state
        .write()
        .await
        .set_env_var("OUTFILE", output.display().to_string());

    let parsed = parser
        .parse("cat < \"$INFILE\" > \"$OUTFILE\"")
        .expect("parse should succeed");

    let result = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    match result {
        ShellAction::Continue(output) => {
            assert_eq!(output.exit_code, ExitCode::SUCCESS);
            assert!(output.stdout.is_empty());
        }
        ShellAction::Exit(_) => panic!("cat should not exit"),
    }

    let content = fs::read_to_string(output).expect("captured output should be readable");
    assert_eq!(content, "hello from file\n");
}

#[tokio::test]
async fn basic_heredoc_execution_feeds_stdin() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let output = dir.path().join("captured.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse(&format!(
            "cat <<EOF > {}\nhello from heredoc\nEOF\n",
            output.display()
        ))
        .expect("parse should succeed");

    let result = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    match result {
        ShellAction::Continue(output) => {
            assert_eq!(output.exit_code, ExitCode::SUCCESS);
            assert!(output.stdout.is_empty());
        }
        ShellAction::Exit(_) => panic!("cat should not exit"),
    }

    let content = fs::read_to_string(output).expect("captured output should be readable");
    assert_eq!(content, "hello from heredoc\n");
}

#[tokio::test]
async fn unquoted_heredoc_expands_environment_variables() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let output = dir.path().join("captured.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");
    state.write().await.set_env_var("HEREDOC_VALUE", "expanded");

    let parsed = parser
        .parse(&format!(
            "cat <<EOF > {}\nvalue:$HEREDOC_VALUE\nEOF\n",
            output.display()
        ))
        .expect("parse should succeed");

    let _ = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    let content = fs::read_to_string(output).expect("captured output should be readable");
    assert_eq!(content, "value:expanded\n");
}

#[tokio::test]
async fn quoted_heredoc_preserves_literal_body_text() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let output = dir.path().join("captured.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");
    state.write().await.set_env_var("HEREDOC_VALUE", "expanded");

    let parsed = parser
        .parse(&format!(
            "cat <<'EOF' > {}\nvalue:$HEREDOC_VALUE\nEOF\n",
            output.display()
        ))
        .expect("parse should succeed");

    let _ = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    let content = fs::read_to_string(output).expect("captured output should be readable");
    assert_eq!(content, "value:$HEREDOC_VALUE\n");
}

#[tokio::test]
async fn last_heredoc_wins_when_multiple_are_present() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let output = dir.path().join("captured.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse(&format!(
            "cat <<FIRST <<SECOND > {}\nfirst body\nFIRST\nsecond body\nSECOND\n",
            output.display()
        ))
        .expect("parse should succeed");

    let _ = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    let content = fs::read_to_string(output).expect("captured output should be readable");
    assert_eq!(content, "second body\n");
}

#[tokio::test]
async fn unquoted_heredoc_runs_command_substitution() {
    let dir = tempfile::tempdir().expect("temp dir should be created");
    let output = dir.path().join("captured.txt");

    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse(&format!(
            "cat <<EOF > {}\n$(printf 'hello')\nEOF\n",
            output.display()
        ))
        .expect("parse should succeed");

    let _ = executor
        .execute(state, &parsed)
        .await
        .expect("execution should succeed");

    let content = fs::read_to_string(output).expect("captured output should be readable");
    assert_eq!(content, "hello\n");
}