camel-cli 0.35.0

Command-line interface for Apache Camel in Rust
//! Integration test for the watch hot path over colocated camel test docs.
//!
//! Regression for the former crash path: a `Camel.toml` with EXPLICIT
//! `routes = ["routes/*.yaml"]` plus `watch = true` used to re-glob and parse
//! `*.test.yaml` sidecars as routes on every reload pass, killing or erroring
//! the run the moment a test doc was saved. Since camel-dsl discovery took
//! ownership of the reserved suffix, wildcard globs skip test documents on
//! every pass (initial load AND reload), so a test-doc save is a no-op.
//!
//! The test spawns the real `camel` binary (via `CARGO_BIN_EXE_camel`)
//! against a fresh `tempfile::TempDir` fixture, waits until the file watcher
//! reports itself armed, rewrites the colocated test doc (identical body plus
//! one trailing newline), and asserts the run is still alive with no error
//! naming the test doc. Design notes mirror `run_exec_guard_test.rs`; the
//! shared subprocess plumbing lives in `tests/common`:
//!
//! - stdout/stderr are piped and drained by two reader threads into separate
//!   buffers so the OS pipe (64 KiB) never fills and deadlocks the child.
//! - The child is wrapped in a kill-on-drop guard: a failed assertion mid-test
//!   cannot leak the process.
//! - Exactly ONE SIGTERM terminates the run; the CLI's `tokio::select!` arm
//!   handles it gracefully (a second signal would force `exit(1)`).
//! - The post-save sleep is 2 s against the default `watch_debounce_ms` of
//!   300 ms: comfortably past the debounce window plus the reload pass.

mod common;

use std::path::Path;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

use common::{drain_to_buffer, send_term, spawn_camel_run, wait_exit_bounded, wait_for_marker};

/// Write `Camel.toml` to `dir/Camel.toml`.
fn write_camel_toml(dir: &Path, body: &str) {
    std::fs::write(dir.join("Camel.toml"), body).expect("write Camel.toml");
}

/// Write `routes/<name>` to `dir/routes/<name>` (creating `routes/` as
/// needed). Used for both the route file and the colocated test doc.
fn write_routes_file(dir: &Path, name: &str, body: &str) {
    let routes_dir = dir.join("routes");
    std::fs::create_dir_all(&routes_dir).expect("create routes/");
    std::fs::write(routes_dir.join(name), body).expect("write routes file");
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

/// Saving a colocated `*.test.yaml` document while `camel run` watches with
/// EXPLICIT `Camel.toml` routes must be a no-op: the reload pass re-globs
/// through discovery, which skips the reserved suffix, so the run stays alive
/// and no error names the test doc.
#[test]
fn watch_reload_test_doc_save_is_noop() {
    let dir = tempfile::tempdir().expect("tempdir");

    // Explicit config routes + watch enabled: the former crash path.
    write_camel_toml(
        dir.path(),
        r#"[default]
routes = ["routes/*.yaml"]
log_level = "INFO"
watch = true
"#,
    );
    write_routes_file(
        dir.path(),
        "demo.yaml",
        r#"routes:
  - id: "demo"
    from: "direct:start"
    steps:
      - to: "mock:result"
"#,
    );
    // Minimal valid test doc colocated with the route it tests.
    let test_doc_body =
        "routeFiles:\n  - demo.yaml\ninputs: []\nexpects:\n  mock:result:\n    count: 1\n";
    write_routes_file(dir.path(), "demo.test.yaml", test_doc_body);

    let mut child = spawn_camel_run(dir.path());
    let out_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
    let err_buf: 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_thread_buf = Arc::clone(&out_buf);
    let err_thread_buf = Arc::clone(&err_buf);
    let out_handle = thread::spawn(move || drain_to_buffer(stdout, out_thread_buf));
    let err_handle = thread::spawn(move || drain_to_buffer(stderr, err_thread_buf));

    // Wait until the watcher is truly armed. The core watcher logs
    // "hot-reload: watching <dir>" only AFTER `watcher.watch()` succeeded,
    // which is a stronger signal than the CLI banner (that races the spawned
    // watcher task and can print before the watch handles are registered).
    let armed = wait_for_marker(
        &mut child,
        &[Arc::clone(&out_buf), Arc::clone(&err_buf)],
        "hot-reload: watching",
        Duration::from_secs(10),
    );
    let captured_so_far = || {
        format!(
            "stdout:\n{}\nstderr:\n{}",
            out_buf.lock().expect("stdout buffer lock poisoned"),
            err_buf.lock().expect("stderr buffer lock poisoned")
        )
    };
    assert!(
        armed,
        "file watcher did not report itself armed within 10 s;\n{}",
        captured_so_far()
    );

    // Save the test doc: identical body plus one trailing newline. Touch
    // semantics without truncating to empty first, so the watcher sees a
    // plain modify event for a `.test.yaml` file.
    std::fs::write(
        dir.path().join("routes/demo.test.yaml"),
        format!("{test_doc_body}\n"),
    )
    .expect("rewrite demo.test.yaml");

    // Sleep past the debounce window (default watch_debounce_ms = 300 ms) so
    // the reload pass has settled before we probe liveness.
    thread::sleep(Duration::from_secs(2));

    // The run must still be alive: the reload pass re-globbed, discovery
    // skipped the test doc, and no route changed.
    let status = child.try_wait().expect("try_wait after test-doc save");
    assert!(
        status.is_none(),
        "camel run died after the colocated test-doc save (status: {status:?}); \
         the reload pass must skip *.test.yaml, not parse it;\n{}",
        captured_so_far()
    );

    // Send exactly ONE SIGTERM; the CLI handles it as graceful shutdown.
    send_term(&child);

    let exited = wait_exit_bounded(&mut child, Duration::from_secs(10));

    let _ = out_handle.join();
    let _ = err_handle.join();

    assert!(
        exited,
        "camel run did not exit within 10 s after SIGTERM;\n{}",
        captured_so_far()
    );

    // No discovery/parse error may name the test doc. The watcher never logs
    // file names on happy paths (change/reload log lines carry no paths), so
    // plain containment on both captured streams is precise: on the former
    // crash path the discovery error text names demo.test.yaml.
    let stdout_text = out_buf.lock().expect("stdout buffer lock poisoned").clone();
    let stderr_text = err_buf.lock().expect("stderr buffer lock poisoned").clone();
    assert!(
        !stderr_text.contains("demo.test.yaml"),
        "stderr names the colocated test doc; the reload pass must skip it:\n{stderr_text}"
    );
    assert!(
        !stdout_text.contains("demo.test.yaml"),
        "stdout names the colocated test doc; the reload pass must skip it:\n{stdout_text}"
    );
}