mxsh 0.2.0

Embeddable POSIX-style shell parser and runtime
Documentation
#![cfg(all(feature = "embed", feature = "test-support"))]

use mxsh::ShellBuilder;
use mxsh::advanced::{PlannedAndOr, PlannedPipelineStage, Planner};
use mxsh::ast::{AndOrList, Command as AstCommand, ParameterOp, Program, Word};
use mxsh::embed::{DeferredWorkDetail, DeferredWorkKind, StdioConfig, TraceEvent};
use mxsh::runtime::testing::{InMemoryRuntime, StringStdioOut};

fn normalize_trace(mut trace: Vec<TraceEvent>) -> Vec<TraceEvent> {
    for event in &mut trace {
        match event {
            TraceEvent::RunStarted {
                timestamp,
                command_id,
                ..
            }
            | TraceEvent::RunFinished {
                timestamp,
                command_id,
                ..
            }
            | TraceEvent::BackgroundJobStarted {
                timestamp,
                command_id,
                ..
            }
            | TraceEvent::BackgroundJobFinished {
                timestamp,
                command_id,
                ..
            } => {
                assert!(!timestamp.is_empty(), "event timestamp should be populated");
                assert!(
                    !command_id.is_empty(),
                    "event command id should be populated"
                );
                *timestamp = "<timestamp>".to_string();
                *command_id = "<command-id>".to_string();
            }
            TraceEvent::DeferredWorkStarted {
                timestamp,
                command_id,
                work_id,
                ..
            }
            | TraceEvent::DeferredWorkFinished {
                timestamp,
                command_id,
                work_id,
                ..
            } => {
                assert!(!timestamp.is_empty(), "event timestamp should be populated");
                assert!(
                    !command_id.is_empty(),
                    "event command id should be populated"
                );
                assert!(!work_id.is_empty(), "event work id should be populated");
                *timestamp = "<timestamp>".to_string();
                *command_id = "<command-id>".to_string();
                *work_id = "<work-id>".to_string();
            }
        }
    }
    trace
}

fn first_stage_argument_words(program: &Program) -> Vec<Word> {
    program
        .body()
        .iter()
        .map(|command_list| {
            let AndOrList::Pipeline(pipeline) = command_list.and_or_list() else {
                panic!("expected pipeline");
            };
            let AstCommand::Simple(command) = &pipeline.commands()[0] else {
                panic!("expected simple command");
            };
            command.arguments()[0].clone()
        })
        .collect()
}

#[test]
fn run_outcome_receives_run_lifecycle_trace_events() {
    let stdout = StringStdioOut::new();
    let mut shell = ShellBuilder::new()
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let result = shell.run(&mut runtime, "echo hello");

    assert_eq!(result.status, 0);
    assert_eq!(stdout.collect(), "hello\n");
    assert_eq!(
        normalize_trace(result.trace),
        vec![
            TraceEvent::RunStarted {
                timestamp: "<timestamp>".to_string(),
                pid: std::process::id(),
                command_id: "<command-id>".to_string(),
                raw_command: Some("echo hello".to_string()),
                canonical_command: Some("echo hello".to_string()),
            },
            TraceEvent::RunFinished {
                timestamp: "<timestamp>".to_string(),
                pid: std::process::id(),
                command_id: "<command-id>".to_string(),
                status: 0,
            },
        ]
    );
}

#[test]
fn run_outcome_receives_deferred_pipeline_work_trace() {
    let stdout = StringStdioOut::new();
    let mut shell = ShellBuilder::new()
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
        let input = stdio.stdin_fd.dup().expect("dup cat stdin").read_all();
        let _ = stdio.stdout_fd.write_str(&input);
        0
    });

    let result = shell.run(&mut runtime, "unset X; echo ${X:=hi} | cat");

    assert_eq!(result.status, 0);
    assert_eq!(stdout.collect(), "hi\n");
    assert_eq!(result.trace.len(), 4);
    assert!(matches!(&result.trace[0], TraceEvent::RunStarted { .. }));
    assert!(matches!(
        &result.trace[1],
        TraceEvent::DeferredWorkStarted {
            kind: DeferredWorkKind::ParameterExpansion,
            ..
        }
    ));
    assert!(matches!(
        &result.trace[2],
        TraceEvent::DeferredWorkFinished {
            kind: DeferredWorkKind::ParameterExpansion,
            status: 0,
            ..
        }
    ));
    assert!(matches!(&result.trace[3], TraceEvent::RunFinished { .. }));
}

#[test]
fn executing_planned_program_preserves_work_ids() {
    let stdout = StringStdioOut::new();
    let mut shell = ShellBuilder::new()
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
        let input = stdio.stdin_fd.dup().expect("dup cat stdin").read_all();
        let _ = stdio.stdout_fd.write_str(&input);
        0
    });

    let program =
        Program::parse("echo ${A:=one} | cat; echo ${B:=two} | cat").expect("script should parse");
    let plan = Planner::new(&mut shell, &mut runtime).prepare(&program);
    let expected_work_ids: Vec<String> = plan
        .command_lists()
        .iter()
        .map(|command_list| match command_list.and_or_list() {
            PlannedAndOr::Pipeline(pipeline) => match &pipeline.stages()[0] {
                PlannedPipelineStage::Lazy(lazy) => lazy
                    .work_id()
                    .expect("expected deferred stage work id")
                    .to_string(),
                other => panic!("expected deferred stage, got {other:?}"),
            },
            other => panic!("expected pipeline, got {other:?}"),
        })
        .collect();
    let result = Planner::new(&mut shell, &mut runtime).execute_plan(&plan);

    assert_eq!(result.status, 0);
    assert_eq!(stdout.collect(), "one\ntwo\n");

    let actual_work_ids: Vec<String> = result
        .trace
        .iter()
        .filter_map(|event| match event {
            TraceEvent::DeferredWorkStarted { work_id, .. } => Some(work_id.clone()),
            _ => None,
        })
        .collect();
    assert_eq!(actual_work_ids, expected_work_ids);
}

#[test]
fn run_outcome_preserves_deferred_work_details() {
    let stdout = StringStdioOut::new();
    let mut shell = ShellBuilder::new()
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
        let input = stdio.stdin_fd.dup().expect("dup cat stdin").read_all();
        let _ = stdio.stdout_fd.write_str(&input);
        0
    });

    let program = Program::parse("echo ${A:=one} | cat").expect("script should parse");
    let expected_word = first_stage_argument_words(&program)
        .into_iter()
        .next()
        .expect("expected a first stage argument");
    let result = shell.run(&mut runtime, "echo ${A:=one} | cat");

    assert_eq!(result.status, 0);
    assert_eq!(stdout.collect(), "one\n");

    let start = result
        .trace
        .iter()
        .find_map(|event| match event {
            TraceEvent::DeferredWorkStarted {
                command_id,
                work_id,
                kind,
                detail,
                ..
            } => Some((command_id.clone(), work_id.clone(), *kind, detail.clone())),
            _ => None,
        })
        .expect("expected deferred-work start event");
    let finish = result
        .trace
        .iter()
        .find_map(|event| match event {
            TraceEvent::DeferredWorkFinished {
                command_id,
                work_id,
                kind,
                detail,
                status,
                ..
            } => Some((
                command_id.clone(),
                work_id.clone(),
                *kind,
                detail.clone(),
                *status,
            )),
            _ => None,
        })
        .expect("expected deferred-work finish event");

    assert_eq!(start.0, finish.0);
    assert_eq!(start.1, finish.1);
    assert_eq!(start.2, DeferredWorkKind::ParameterExpansion);
    assert_eq!(finish.2, DeferredWorkKind::ParameterExpansion);
    assert_eq!(finish.4, 0);
    assert_eq!(
        start.3,
        DeferredWorkDetail::ParameterExpansion {
            word: expected_word.clone(),
            op: ParameterOp::Equal,
        }
    );
    assert_eq!(
        finish.3,
        DeferredWorkDetail::ParameterExpansion {
            word: expected_word,
            op: ParameterOp::Equal,
        }
    );
}