cuenv-core 0.40.6

Core types and error handling for the cuenv ecosystem
Documentation
//! End-to-end test that the executor's cache wrapper actually
//! short-circuits a second invocation of the same task.
//!
//! Strategy: the task writes a side-effect file (`workdir/marker.txt`)
//! every time it runs. After the first run we delete the marker; if the
//! second run is a real cache hit the marker should remain absent
//! afterwards (because no process spawned).

use cuenv_cas::{LocalActionCache, LocalCas};
use cuenv_core::OutputCapture;
use cuenv_core::tasks::cache::TaskCacheConfig;
use cuenv_core::tasks::executor::{ExecutorConfig, TaskExecutor};
use cuenv_core::tasks::{Input, Task, TaskCacheMode, TaskCachePolicy};
use cuenv_vcs::WalkHasher;
use std::collections::BTreeMap;
use std::fs;
use std::sync::Arc;
use tempfile::TempDir;

fn build_executor(workspace: &std::path::Path, cache_root: &std::path::Path) -> TaskExecutor {
    let cas = Arc::new(LocalCas::open(cache_root).unwrap());
    let action_cache = Arc::new(LocalActionCache::open(cache_root).unwrap());
    let vcs_hasher = Arc::new(WalkHasher::new(workspace));

    let cache = TaskCacheConfig {
        cas: cas.clone(),
        action_cache: action_cache.clone(),
        vcs_hasher,
        vcs_hasher_root: workspace.to_path_buf(),
        cuenv_version: "test".to_string(),
        runtime_identity_properties: BTreeMap::new(),
        cache_disabled_reason: None,
    };

    let config = ExecutorConfig {
        capture_output: OutputCapture::Capture,
        project_root: workspace.to_path_buf(),
        cache: Some(cache),
        ..Default::default()
    };
    TaskExecutor::new(config)
}

#[tokio::test]
async fn second_run_with_unchanged_inputs_is_a_cache_hit() {
    let workspace = TempDir::new().unwrap();
    let cache_root = TempDir::new().unwrap();
    fs::write(workspace.path().join("input.txt"), "v1").unwrap();

    let executor = build_executor(workspace.path(), cache_root.path());

    // The task touches `marker.txt` as a side effect we can observe.
    let task = Task {
        command: "sh".to_string(),
        args: vec![
            "-c".to_string(),
            "touch marker.txt; cat input.txt > out.txt".to_string(),
        ],
        inputs: vec![Input::Path("input.txt".to_string())],
        outputs: vec!["out.txt".to_string()],
        cache: Some(TaskCachePolicy {
            mode: TaskCacheMode::ReadWrite,
            max_age: None,
        }),
        ..Task::default()
    };

    // First run: marker should appear, output should be produced.
    let result1 = executor.execute_task("touch", &task).await.unwrap();
    assert!(result1.success);
    assert!(workspace.path().join("marker.txt").exists());
    assert_eq!(fs::read(workspace.path().join("out.txt")).unwrap(), b"v1");

    // Wipe both side effects so we can prove they're not regenerated by exec.
    fs::remove_file(workspace.path().join("marker.txt")).unwrap();
    fs::remove_file(workspace.path().join("out.txt")).unwrap();

    // Second run: should be a cache hit. marker.txt should NOT come back
    // (no process spawned), but out.txt SHOULD be materialized from CAS.
    let result2 = executor.execute_task("touch", &task).await.unwrap();
    assert!(result2.success);
    assert!(
        !workspace.path().join("marker.txt").exists(),
        "marker.txt was recreated, meaning the task actually re-ran instead of hitting the cache"
    );
    assert!(
        workspace.path().join("out.txt").exists(),
        "out.txt was not materialized from the cache"
    );
    assert_eq!(fs::read(workspace.path().join("out.txt")).unwrap(), b"v1");
}

#[tokio::test]
async fn cache_invalidates_when_input_changes() {
    let workspace = TempDir::new().unwrap();
    let cache_root = TempDir::new().unwrap();
    fs::write(workspace.path().join("input.txt"), "v1").unwrap();

    let executor = build_executor(workspace.path(), cache_root.path());
    let task = Task {
        command: "sh".to_string(),
        args: vec![
            "-c".to_string(),
            "touch marker.txt; cat input.txt > out.txt".to_string(),
        ],
        inputs: vec![Input::Path("input.txt".to_string())],
        outputs: vec!["out.txt".to_string()],
        cache: Some(TaskCachePolicy {
            mode: TaskCacheMode::ReadWrite,
            max_age: None,
        }),
        ..Task::default()
    };

    let result1 = executor.execute_task("t", &task).await.unwrap();
    assert!(result1.success);

    // Change the input — cache should miss and the task should run again.
    fs::remove_file(workspace.path().join("marker.txt")).unwrap();
    fs::write(workspace.path().join("input.txt"), "v2").unwrap();

    let result2 = executor.execute_task("t", &task).await.unwrap();
    assert!(result2.success);
    assert!(
        workspace.path().join("marker.txt").exists(),
        "input change should have triggered a real re-execution"
    );
    assert_eq!(fs::read(workspace.path().join("out.txt")).unwrap(), b"v2");
}

#[tokio::test]
async fn task_without_inputs_is_never_cached() {
    let workspace = TempDir::new().unwrap();
    let cache_root = TempDir::new().unwrap();

    let executor = build_executor(workspace.path(), cache_root.path());
    let task = Task {
        command: "sh".to_string(),
        args: vec!["-c".to_string(), "touch marker.txt".to_string()],
        // no `inputs`
        ..Task::default()
    };

    let result1 = executor.execute_task("no-inputs", &task).await.unwrap();
    assert!(result1.success);
    fs::remove_file(workspace.path().join("marker.txt")).unwrap();

    let result2 = executor.execute_task("no-inputs", &task).await.unwrap();
    assert!(result2.success);
    assert!(
        workspace.path().join("marker.txt").exists(),
        "task without declared inputs must always re-run"
    );
}