camel-cli 0.35.0

Command-line interface for Apache Camel in Rust
//! End-to-end integration test for the `camel run` empty-discovery warning.
//!
//! When the route discovery patterns match zero files (e.g. the operator has
//! `routes.yaml` at the workspace root while the default pattern is
//! `routes/*.yaml`), `camel run` must emit a WARN naming the patterns before
//! starting with no routes — otherwise the process starts silently, no server
//! opens, and the operator concludes the feature is broken (bd rc-1110).
//!
//! The harness mirrors `run_exec_guard_test.rs`: spawn the real `camel`
//! binary with piped stdout/stderr, poll the captured buffer for the
//! observation, then send exactly one SIGTERM.

use std::io::Read;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};

fn drain_to_buffer<R: Read + Send + 'static>(mut reader: R, buffer: Arc<Mutex<String>>) {
    let mut chunk = [0u8; 4096];
    loop {
        match reader.read(&mut chunk) {
            Ok(0) => return,
            Ok(n) => {
                let text = String::from_utf8_lossy(&chunk[..n]);
                let mut guard = buffer.lock().expect("buffer lock poisoned");
                guard.push_str(&text);
            }
            Err(e) => {
                eprintln!("reader thread io error: {e}");
                return;
            }
        }
    }
}

/// Launch `camel run --no-watch` against `dir`'s `Camel.toml` with piped
/// output. The working dir is `dir` so the default discovery pattern
/// `routes/*.yaml` resolves relative to the fixture root.
fn spawn_camel_run(dir: &Path) -> Child {
    let config_path = dir.join("Camel.toml");
    Command::new(env!("CARGO_BIN_EXE_camel"))
        .arg("run")
        .arg("--no-watch")
        .arg("--config")
        .arg(&config_path)
        .current_dir(dir)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .stdin(Stdio::null())
        .spawn()
        .expect("failed to spawn `camel` binary")
}

/// Run `camel run` against `dir`, poll the captured output until it contains
/// `observe` (capped at `timeout`), then send exactly ONE SIGTERM and wait
/// for a graceful exit (10 s ceiling from the signal, force-kill past it).
/// Returns `(exit_code, captured_output, observed)`.
fn run_observe_then_signal(dir: &Path, observe: &str, timeout: Duration) -> (i32, String, bool) {
    let mut child = spawn_camel_run(dir);
    let buffer: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));

    let stdout = child
        .stdout
        .take()
        .expect("child stdout was configured as piped");
    let stderr = child
        .stderr
        .take()
        .expect("child stderr was configured as piped");

    let out_buf = Arc::clone(&buffer);
    let err_buf = Arc::clone(&buffer);
    let out_handle = thread::spawn(move || drain_to_buffer(stdout, out_buf));
    let err_handle = thread::spawn(move || drain_to_buffer(stderr, err_buf));

    let start = Instant::now();
    let step = Duration::from_millis(25);
    let observed = loop {
        {
            let guard = buffer.lock().expect("buffer lock poisoned");
            if guard.contains(observe) {
                break true;
            }
        }
        if start.elapsed() >= timeout {
            break false;
        }
        if let Ok(Some(_)) = child.try_wait() {
            break false;
        }
        thread::sleep(step);
    };

    if observed {
        // Send exactly ONE SIGTERM. The CLI's `tokio::select!` arm handles
        // SIGTERM → graceful shutdown → `Ok(())` → exit 0. A second signal
        // would force `exit(1)`, so we must not double-tap.
        let kill_status = Command::new("kill")
            .arg("-TERM")
            .arg(child.id().to_string())
            .status()
            .expect("failed to spawn `kill -TERM`");
        assert!(
            kill_status.success(),
            "`kill -TERM` returned non-zero: {kill_status:?}"
        );
    }

    // Bounded graceful-exit wait: 10 s measured from the SIGTERM, decoupled
    // from the observation phase so a slow observation cannot eat the exit
    // budget under concurrent load (bd rc-5dag).
    let exit_start = Instant::now();
    let exit_code = loop {
        match child.try_wait() {
            Ok(Some(status)) => break status.code().unwrap_or(-1),
            Ok(None) => {
                if exit_start.elapsed() >= Duration::from_secs(10) {
                    let _ = child.kill();
                    let _ = child.wait();
                    break -1;
                }
                thread::sleep(step);
            }
            Err(e) => panic!("try_wait failed: {e}"),
        }
    };

    let _ = out_handle.join();
    let _ = err_handle.join();
    let captured = buffer.lock().expect("buffer lock poisoned").clone();
    (exit_code, captured, observed)
}

/// A discovery pattern that matches zero files (no `routes/` directory is
/// created) must produce a WARN naming the patterns, and the process must
/// still start and shut down gracefully (exit 0).
#[test]
fn empty_discovery_emits_warn_and_starts() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(
        dir.path().join("Camel.toml"),
        r#"[default]
routes = ["routes/*.yaml"]
log_level = "INFO"
watch = false
"#,
    )
    .expect("write Camel.toml");
    // Intentionally NO routes/ directory: the glob matches nothing.

    let (exit_code, output, observed) = run_observe_then_signal(
        dir.path(),
        "matched zero route files",
        Duration::from_secs(60),
    );

    assert!(
        observed,
        "expected a WARN containing `matched zero route files` when the \
         discovery glob matches nothing; got:\n{output}"
    );
    assert!(
        output.contains("routes/*.yaml"),
        "expected the WARN to name the discovery patterns; got:\n{output}"
    );
    assert_eq!(
        exit_code, 0,
        "expected graceful shutdown (exit 0) after the WARN; got {exit_code}\n--- captured ---\n{output}\n--- end ---"
    );
}

/// Wildcard-matches-ONLY-a-test-doc variant: the glob `routes/*.test.yaml`
/// expands to a single reserved test document, which discovery skips. The
/// observable behavior must be identical to an empty glob: the run stays
/// alive, the zero-routes WARN fires naming the pattern, and NO discovery
/// error names the test doc (only a literal `.test.yaml` pattern
/// hard-errors; a wildcard skip is silent).
#[test]
fn wildcard_over_only_test_doc_warns_zero_routes_not_error() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::create_dir(dir.path().join("routes")).expect("mkdir routes");
    std::fs::write(
        dir.path().join("Camel.toml"),
        r#"[default]
routes = ["routes/*.test.yaml"]
log_level = "INFO"
watch = false
"#,
    )
    .expect("write Camel.toml");
    // Deliberately invalid route bytes: a test doc is never read by
    // discovery, so this content must not surface as any error.
    std::fs::write(
        dir.path().join("routes/demo.test.yaml"),
        "not: [a route document",
    )
    .expect("write demo.test.yaml");

    let (exit_code, output, observed) = run_observe_then_signal(
        dir.path(),
        "matched zero route files",
        Duration::from_secs(60),
    );

    assert!(
        observed,
        "expected the zero-routes WARN when the glob matches only a test \
         doc and the run stays alive; got:\n{output}"
    );
    assert!(
        output.contains("routes/*.test.yaml"),
        "expected the WARN to name the discovery patterns; got:\n{output}"
    );
    assert!(
        !output.contains("demo.test.yaml"),
        "a wildcard match on a test doc must be skipped silently, not \
         surfaced as a discovery error naming it; got:\n{output}"
    );
    assert_eq!(
        exit_code, 0,
        "expected the run to stay alive until SIGTERM and exit gracefully (0); got {exit_code}\n--- captured ---\n{output}\n--- end ---"
    );
}

/// Default-path variant: NO `routes` key in Camel.toml and NO `routes/`
/// directory. The default glob `routes/*.yaml` expands to zero files, so the
/// WARN must name the raw default pattern — not the empty expanded list —
/// or the operator cannot see which glob matched nothing (bd rc-1110).
#[test]
fn empty_discovery_default_path_warns_with_raw_pattern() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(
        dir.path().join("Camel.toml"),
        r#"[default]
log_level = "INFO"
watch = false
"#,
    )
    .expect("write Camel.toml");
    // Intentionally NO `routes` key and NO `routes/` directory: the default
    // glob `routes/*.yaml` matches nothing.

    let (exit_code, output, observed) = run_observe_then_signal(
        dir.path(),
        "matched zero route files",
        Duration::from_secs(60),
    );

    assert!(
        observed,
        "expected a WARN containing `matched zero route files` on the default \
         path; got:\n{output}"
    );
    assert!(
        output.contains("routes/*.yaml"),
        "expected the WARN to name the raw default pattern `routes/*.yaml` \
         instead of the empty expanded list; got:\n{output}"
    );
    assert!(
        !output.contains("patterns []"),
        "expected the WARN NOT to print the empty expanded pattern list; got:\n{output}"
    );
    assert_eq!(
        exit_code, 0,
        "expected graceful shutdown (exit 0) after the WARN; got {exit_code}\n--- captured ---\n{output}\n--- end ---"
    );
}