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 gshell::{
    jobs::JobState,
    parser::Parser,
    runtime::{BootstrapExecutor, Executor},
    shell::{ExitCode, ShellAction, ShellState},
};

#[cfg(unix)]
use nix::{
    sys::signal::{Signal, kill},
    unistd::Pid,
};

#[cfg(unix)]
#[tokio::test]
async fn trailing_ampersand_starts_external_command_in_background() {
    use std::time::Duration;

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

    let parsed = parser.parse("sleep 1 &").expect("parse should succeed");
    let result = executor
        .execute(state.clone(), &parsed)
        .await
        .expect("execution should succeed");

    let job = {
        let guard = state.read().await;
        let jobs = guard.jobs().iter().collect::<Vec<_>>();
        assert_eq!(jobs.len(), 1);
        jobs[0].clone()
    };

    match result {
        ShellAction::Continue(output) => {
            assert_eq!(output.exit_code, ExitCode::SUCCESS);
            assert_eq!(output.stdout, format!("[{}] {}\n", job.id(), job.summary()));
        }
        ShellAction::Exit(_) => panic!("background command should not exit the shell"),
    }

    assert_eq!(job.state(), JobState::Running);

    kill(Pid::from_raw(job.pgid() as i32), Signal::SIGKILL).expect("SIGKILL should be delivered");
    tokio::time::sleep(Duration::from_millis(100)).await;
    gshell::runtime::refresh_job_statuses(state.clone())
        .await
        .expect("job refresh should succeed");

    let guard = state.read().await;
    assert_eq!(
        guard
            .jobs()
            .get(job.id())
            .expect("job should exist")
            .state(),
        JobState::Completed
    );
}

#[cfg(unix)]
#[tokio::test]
async fn trailing_ampersand_starts_pipeline_in_background() {
    use std::time::Duration;

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

    let parsed = parser
        .parse("sleep 1 | cat &")
        .expect("parse should succeed");
    let result = executor
        .execute(state.clone(), &parsed)
        .await
        .expect("execution should succeed");

    let (job_id, pgid, summary, process_count, job_state) = {
        let guard = state.read().await;
        let jobs = guard.jobs().iter().collect::<Vec<_>>();
        assert_eq!(jobs.len(), 1);
        let job = jobs[0];
        (
            job.id(),
            job.pgid(),
            job.summary().to_string(),
            job.processes().len(),
            job.state(),
        )
    };

    match result {
        ShellAction::Continue(output) => {
            assert_eq!(output.exit_code, ExitCode::SUCCESS);
            assert_eq!(output.stdout, format!("[{job_id}] {summary}\n"));
        }
        ShellAction::Exit(_) => panic!("background pipeline should not exit the shell"),
    }

    assert_eq!(process_count, 2);
    assert_eq!(job_state, JobState::Running);

    kill(Pid::from_raw(pgid as i32), Signal::SIGKILL).expect("SIGKILL should be delivered");
    tokio::time::sleep(Duration::from_millis(100)).await;
    gshell::runtime::refresh_job_statuses(state.clone())
        .await
        .expect("job refresh should succeed");

    let guard = state.read().await;
    assert_eq!(
        guard.jobs().get(job_id).expect("job should exist").state(),
        JobState::Completed
    );
}

#[tokio::test]
async fn external_command_creates_completed_foreground_job_record() {
    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

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

    match result {
        ShellAction::Continue(output) => assert_eq!(output.exit_code, ExitCode::SUCCESS),
        ShellAction::Exit(_) => panic!("true should not exit the shell"),
    }

    let guard = state.read().await;
    let jobs = guard.jobs().iter().collect::<Vec<_>>();
    assert_eq!(jobs.len(), 1);
    let job = jobs[0];
    assert_eq!(job.state(), JobState::Completed);
    assert_eq!(job.processes().len(), 1);
    assert_eq!(job.pgid(), job.processes()[0].pid());
    assert_eq!(guard.jobs().foreground_job(), None);
}

#[tokio::test]
async fn pipeline_records_one_job_with_multiple_processes() {
    let parser = Parser::default();
    let executor = BootstrapExecutor;
    let state = ShellState::shared().await.expect("state should initialize");

    let parsed = parser
        .parse("printf hi | cat")
        .expect("parse should succeed");
    let result = executor
        .execute(state.clone(), &parsed)
        .await
        .expect("execution should succeed");

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

    let guard = state.read().await;
    let jobs = guard.jobs().iter().collect::<Vec<_>>();
    assert_eq!(jobs.len(), 1);
    let job = jobs[0];
    assert_eq!(job.state(), JobState::Completed);
    assert_eq!(job.processes().len(), 2);
    assert_eq!(job.summary(), "printf hi | cat");
}

#[cfg(unix)]
#[tokio::test]
async fn foreground_stop_marks_job_stopped() {
    use std::time::Duration;

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

    let state_for_task = state.clone();
    let task = tokio::spawn(async move {
        executor
            .execute(state_for_task, &parsed)
            .await
            .expect("execution should succeed")
    });

    let pid = loop {
        if let Some(pid) = {
            let guard = state.read().await;
            guard.jobs().foreground_job().and_then(|job_id| {
                guard
                    .jobs()
                    .get(job_id)
                    .and_then(|job| job.processes().first().map(|process| process.pid()))
            })
        } {
            break pid;
        }

        tokio::time::sleep(Duration::from_millis(25)).await;
    };

    kill(Pid::from_raw(pid as i32), Signal::SIGSTOP).expect("SIGSTOP should be delivered");

    let result = tokio::time::timeout(Duration::from_secs(5), task)
        .await
        .expect("execution should return after stop")
        .expect("task should join successfully");

    match result {
        ShellAction::Continue(output) => assert!(output.exit_code.is_failure()),
        ShellAction::Exit(_) => panic!("sleep should not exit the shell"),
    }

    let guard = state.read().await;
    let jobs = guard.jobs().iter().collect::<Vec<_>>();
    assert_eq!(jobs.len(), 1);
    assert_eq!(jobs[0].state(), JobState::Stopped);
    assert_eq!(guard.jobs().foreground_job(), None);
}