bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
#[cfg(any(unix, windows))]
use crate::backends::typed::CommandInvocation;
#[cfg(any(unix, windows))]
use crate::execution::TEST_ENV_LOCK;
#[cfg(any(unix, windows))]
use crate::execution::command::run_invocation_capture;
#[cfg(windows)]
use crate::execution::command::run_shell_capture;
use crate::execution::command::{
    PROGRESS_OUTPUT_LIMIT, push_tail, recent_output_line, strip_sudo_from_apt_commands,
};
use crate::ui::progress::{
    ProgressMessage, ProgressSnapshot, apply_progress_message, progress_summary_lines,
};
#[cfg(unix)]
use crate::util::{now_secs, shell_quote};
use std::collections::BTreeMap;

#[cfg(windows)]
#[test]
fn typed_capture_executes_windows_cmd_shims_from_path() {
    let _guard = TEST_ENV_LOCK.lock().unwrap();
    let root = std::env::temp_dir().join(format!("bot-forge-cmd-shim-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&root);
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("demo"), "#!/bin/sh\necho wrong\n").unwrap();
    std::fs::write(root.join("demo.cmd"), "@echo 1.4.1\r\n").unwrap();
    let path = std::env::var_os("PATH");
    let path_ext = std::env::var_os("PATHEXT");
    crate::util::set_process_var("PATH", &root);
    crate::util::set_process_var("PATHEXT", ".COM;.EXE;.BAT;.CMD");
    let result = run_invocation_capture(&CommandInvocation {
        program: "demo".into(),
        args: vec!["--version".into()],
        env: BTreeMap::new(),
        current_dir: None,
        clear_env: false,
        null_stdin: false,
        timeout_secs: Some(2),
        inactivity_timeout_secs: None,
        idempotent: true,
        success_codes: vec![0],
        stdout_contains: None,
    });
    match path {
        Some(value) => crate::util::set_process_var("PATH", value),
        None => crate::util::remove_process_var("PATH"),
    }
    match path_ext {
        Some(value) => crate::util::set_process_var("PATHEXT", value),
        None => crate::util::remove_process_var("PATHEXT"),
    }
    std::fs::remove_dir_all(root).unwrap();
    assert_eq!(result.unwrap().trim(), "1.4.1");
}

#[cfg(unix)]
fn test_invocation(
    script: &str,
    success_codes: Vec<i32>,
    timeout_secs: Option<u64>,
) -> CommandInvocation {
    CommandInvocation {
        program: "sh".into(),
        args: vec!["-c".into(), script.into()],
        env: BTreeMap::new(),
        current_dir: None,
        clear_env: false,
        null_stdin: false,
        timeout_secs,
        inactivity_timeout_secs: None,
        idempotent: true,
        success_codes,
        stdout_contains: None,
    }
}

#[cfg(unix)]
#[test]
fn typed_capture_honors_configured_success_codes() {
    let _guard = TEST_ENV_LOCK.lock().unwrap();
    assert!(run_invocation_capture(&test_invocation("exit 7", vec![7], Some(2))).is_ok());
    assert!(run_invocation_capture(&test_invocation("exit 8", vec![7], Some(2))).is_err());
}

#[cfg(unix)]
#[test]
fn typed_capture_honors_stdout_predicates() {
    let _guard = TEST_ENV_LOCK.lock().unwrap();
    let mut invocation = test_invocation("printf rust-src", vec![0], Some(2));
    invocation.stdout_contains = Some("rust-src".into());
    assert!(run_invocation_capture(&invocation).is_ok());
    invocation.stdout_contains = Some("miri".into());
    assert!(run_invocation_capture(&invocation).is_err());
}

#[cfg(unix)]
#[test]
fn typed_capture_terminates_on_timeout() {
    let _guard = TEST_ENV_LOCK.lock().unwrap();
    let started = std::time::Instant::now();
    let error = run_invocation_capture(&test_invocation("sleep 5", vec![0], Some(1))).unwrap_err();
    assert!(error.to_string().contains("timed out"));
    assert!(started.elapsed() < std::time::Duration::from_secs(3));
}

#[cfg(unix)]
#[test]
fn typed_capture_timeout_terminates_the_entire_process_group() {
    let _guard = TEST_ENV_LOCK.lock().unwrap();
    let marker = std::env::temp_dir().join(format!(
        "bot-forge-process-tree-{}-{}",
        std::process::id(),
        now_secs()
    ));
    let script = format!("(sleep 2; printf orphan > {}) & wait", shell_quote(&marker));
    let error = run_invocation_capture(&test_invocation(&script, vec![0], Some(1))).unwrap_err();
    assert!(error.to_string().contains("timed out"));
    std::thread::sleep(std::time::Duration::from_millis(1500));
    assert!(
        !marker.exists(),
        "timeout left a descendant process running"
    );
}

#[test]
fn strips_sudo_only_from_apt_commands() {
    assert_eq!(
        strip_sudo_from_apt_commands("sudo apt-get update"),
        "apt-get update"
    );
    assert_eq!(
        strip_sudo_from_apt_commands("cd /tmp && sudo apt install -y cmake"),
        "cd /tmp && apt install -y cmake"
    );
    assert_eq!(
        strip_sudo_from_apt_commands("sudo systemctl restart demo"),
        "sudo systemctl restart demo"
    );
}

#[test]
fn recent_output_line_keeps_the_latest_nonempty_line() {
    assert_eq!(recent_output_line("line1\nline2\n\nline3\n"), "line3");
}

#[test]
fn recent_output_line_reports_empty_output() {
    assert_eq!(recent_output_line("\n\r\n"), "no command output");
}

#[test]
fn command_output_buffer_keeps_only_the_tail() {
    let mut output = String::new();
    push_tail(&mut output, &"x".repeat(PROGRESS_OUTPUT_LIMIT * 2));
    assert_eq!(output.len(), PROGRESS_OUTPUT_LIMIT);
}

#[test]
fn progress_summary_includes_all_active_commands() {
    let active = BTreeMap::from([
        (
            1,
            ProgressSnapshot {
                id: 1,
                label: "demo-tool".into(),
                step: Some((2, 8)),
                elapsed: 123,
                recent: "recent output".into(),
                frame: 0,
            },
        ),
        (
            2,
            ProgressSnapshot {
                id: 2,
                label: "other-tool".into(),
                step: None,
                elapsed: 5,
                recent: "Compiling demo".into(),
                frame: 1,
            },
        ),
    ]);
    let lines = progress_summary_lines(&active);
    let line = lines.join("\n");

    assert_eq!(lines.len(), 3);
    assert!(line.contains("2 running"));
    assert!(line.contains("demo-tool"));
    assert!(line.contains("2/8"));
    assert!(line.contains("123s"));
    assert!(line.contains("recent output"));
    assert!(line.contains("other-tool"));
}

#[test]
fn progress_summary_omits_unknown_zero_progress() {
    let active = BTreeMap::from([(
        1,
        ProgressSnapshot {
            id: 1,
            label: "demo-tool".into(),
            step: None,
            elapsed: 5,
            recent: "Compiling demo".into(),
            frame: 0,
        },
    )]);
    let line = progress_summary_lines(&active).join("\n");
    assert!(line.contains("demo-tool"));
    assert!(!line.contains("0/0"));
}

#[test]
fn progress_pause_hides_updates_until_output_is_complete() {
    let mut active = BTreeMap::from([(
        1,
        ProgressSnapshot {
            id: 1,
            label: "demo-tool".into(),
            step: None,
            elapsed: 5,
            recent: "Compiling demo".into(),
            frame: 0,
        },
    )]);
    let mut acknowledgements = Vec::new();
    let mut pause_depth = 0;
    let (sender, _receiver) = std::sync::mpsc::sync_channel(0);

    apply_progress_message(
        ProgressMessage::Pause(sender),
        &mut active,
        &mut acknowledgements,
        &mut pause_depth,
    );
    assert_eq!(pause_depth, 1);
    assert_eq!(acknowledgements.len(), 1);

    apply_progress_message(
        ProgressMessage::Resume,
        &mut active,
        &mut acknowledgements,
        &mut pause_depth,
    );
    assert_eq!(pause_depth, 0);
    assert_eq!(active.len(), 1);
}

#[cfg(windows)]
#[test]
fn preserves_quoted_powershell_command_on_windows() {
    let output = run_shell_capture(
        "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -NonInteractive -Command \"$value = 'quoted-command-ok'; Write-Output $value\"",
    )
    .unwrap();
    assert_eq!(output.trim(), "quoted-command-ok");
}