use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use indexmap::IndexMap;
use owo_colors::OwoColorize;
use crate::asset::{AssetGraph, StepAssets};
use crate::asset_kind::AssetKind;
use crate::contract;
use crate::engine::{
ALLOW_UNTESTED_ENGINE_ENV, Engine, ExtensionRecheck, OLDEST_ENGINE, ProtocolSql,
SUPPORTED_ENGINE_RANGE, check_extension_installs, check_extension_pins, protocol_sql,
recheck_extension_pins,
};
use crate::error::{Error, Result};
use crate::manifest::{MANIFEST_FILENAME, Manifest, Param, RetryPolicy};
use crate::operator;
use crate::precondition;
use crate::precondition::Precondition;
use crate::state::{self, SkipReason, StateBackend, StepStatus};
fn load_dotenv_files(dir: &Path, dotenv_paths: &[String]) -> HashMap<String, String> {
let mut vars = HashMap::new();
for path_str in dotenv_paths {
let path = dir.join(path_str);
if let Ok(iter) = dotenvy::from_path_iter(&path) {
for (key, value) in iter.flatten() {
vars.insert(key, value);
}
}
}
vars
}
pub fn resolve_params(
manifest_params: &IndexMap<String, Param>,
dotenv_vars: &HashMap<String, String>,
cli_params: &[(String, String)],
) -> Result<HashMap<String, String>> {
let mut resolved: HashMap<String, String> = HashMap::new();
let cli_map: HashMap<&str, &str> = cli_params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
for (name, param) in manifest_params {
let value = if let Some(v) = cli_map.get(name.as_str()) {
Some(v.to_string())
} else if let Some(v) = dotenv_vars.get(name) {
Some(v.clone())
} else {
param.default.clone()
};
match value {
Some(v) => {
let env_key = format!("ARC_PARAM_{}", name.to_uppercase());
resolved.insert(env_key, v);
}
None => {
return Err(Error::MissingParam { name: name.clone() });
}
}
}
Ok(resolved)
}
pub fn backoff_duration(policy: &RetryPolicy, attempt: u32) -> Duration {
let secs = policy.backoff_sec * 2f64.powi((attempt as i32) - 1);
Duration::from_secs_f64(secs)
}
fn execute_hook(
slot: &str,
hook: &crate::manifest::Step,
engine: &dyn Engine,
extensions: &mut ExtensionRecheck,
db_path: &Path,
dir: &Path,
env: &HashMap<String, String>,
) -> Result<()> {
if let Some(source) = ProtocolSql::hook(slot, hook, dir) {
check_again(extensions, engine, &source)?;
engine.execute_sql(db_path, &source.path, env, None, extensions.loaded_mut())?;
} else if let Some(ref command) = hook.command {
engine.execute_command(command, env, false, None)?;
}
Ok(())
}
fn check_again(
extensions: &mut ExtensionRecheck,
engine: &dyn Engine,
source: &ProtocolSql,
) -> Result<()> {
for warning in extensions.check(engine, source)? {
eprintln!("{} {}", "warning:".yellow(), warning);
}
Ok(())
}
fn report_hook_failure(slot: &str, hook: &crate::manifest::Step, e: &Error) {
if e.refused_before_it_ran() {
eprintln!("{} {}", "error:".red(), e);
} else {
eprintln!(
"{} {} hook '{}' failed: {}",
"warning:".yellow(),
slot,
hook.name,
e
);
}
}
#[cfg(test)]
pub fn run(dir: &Path, engine: &dyn Engine, state: &dyn StateBackend, force: bool) -> Result<()> {
run_with_params(dir, engine, state, force, &[])
}
pub(crate) fn check_engine_version(
found: Option<&semver::Version>,
manifest_constraint: Option<&str>,
allow_untested: bool,
) -> Result<Vec<String>> {
let supported = semver::VersionReq::parse(SUPPORTED_ENGINE_RANGE)
.expect("SUPPORTED_ENGINE_RANGE is a valid semver requirement");
let manifest_req =
manifest_constraint.and_then(|s| semver::VersionReq::parse(s).ok().map(|r| (s, r)));
let Some(ver) = found else {
let required = match &manifest_req {
Some((s, _)) => format!("{s} and {SUPPORTED_ENGINE_RANGE}"),
None => SUPPORTED_ENGINE_RANGE.to_string(),
};
return Ok(vec![format!(
"could not detect engine version — skipping version check (requires {required})"
)]);
};
if let Some((s, req)) = &manifest_req
&& !req.matches(ver)
{
return Err(Error::VersionMismatch {
required: s.to_string(),
found: ver.to_string(),
});
}
if supported.matches(ver) {
return Ok(Vec::new());
}
let oldest = semver::Version::parse(OLDEST_ENGINE).expect("OLDEST_ENGINE is a valid version");
if (ver.major, ver.minor, ver.patch) < (oldest.major, oldest.minor, oldest.patch) {
return Err(Error::EngineTooOld {
found: ver.to_string(),
oldest: OLDEST_ENGINE,
range: SUPPORTED_ENGINE_RANGE,
});
}
if allow_untested {
return Ok(vec![format!(
"engine DuckDB {ver} is outside the versions arc is tested on \
({SUPPORTED_ENGINE_RANGE}); running anyway because {ALLOW_UNTESTED_ENGINE_ENV} is set"
)]);
}
Err(Error::UntestedEngine {
found: ver.to_string(),
range: SUPPORTED_ENGINE_RANGE,
override_var: ALLOW_UNTESTED_ENGINE_ENV,
})
}
pub(crate) fn engine_change_notice(
previous: Option<&str>,
current: &semver::Version,
) -> Option<String> {
let previous = crate::engine::parse_engine_version(previous?)?.version;
if (previous.major, previous.minor) == (current.major, current.minor) {
return None;
}
let mut notice = format!(
"this Protocol last ran on DuckDB {previous}, and this run uses DuckDB {current}; \
results can differ between them"
);
if previous.major == 1 && current.major == 2 {
notice.push_str(
"\nDuckDB 2.0 changes four results without an error: `~` and `!~` match part of a \
string; integer `//` and `%` by zero raise an error; `x -> …` lambdas are rejected; \
`ORDER BY` a non-integer literal is rejected",
);
}
Some(notice)
}
pub(crate) fn possible_copies(dir: &Path) -> Vec<contract::PossibleCopy> {
let stem = MANIFEST_FILENAME
.strip_suffix(".yaml")
.expect("MANIFEST_FILENAME ends with .yaml");
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter(|entry| entry.path().is_file())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| {
name.starts_with(stem) && name.ends_with(".yaml") && name != MANIFEST_FILENAME
})
.collect();
names.sort();
names
.into_iter()
.map(|name| contract::PossibleCopy {
warning: format!(
"`{name}` sits beside {MANIFEST_FILENAME} and arc did not read it: it may be a \
copy a synced drive such as Dropbox or OneDrive set aside when two people saved \
the Protocol, and a step in it that {MANIFEST_FILENAME} lacks does not run"
),
file: name,
})
.collect()
}
pub fn run_with_params(
dir: &Path,
engine: &dyn Engine,
state: &dyn StateBackend,
force: bool,
cli_params: &[(String, String)],
) -> Result<()> {
let manifest = Manifest::load(dir)?;
let possible_copies = possible_copies(dir);
for copy in &possible_copies {
eprintln!("{} {}", "warning:".yellow(), copy.warning);
}
let mut extensions = ExtensionRecheck::default();
let mut engine_version: Option<semver::Version> = None;
if manifest.has_sql_steps() {
let info = engine.preflight()?;
engine_version = info.version.clone();
let allow_untested = crate::engine::untested_engine_allowed();
for warning in check_engine_version(
info.version.as_ref(),
manifest.engine_version.as_deref(),
allow_untested,
)? {
eprintln!("{} {}", "warning:".yellow(), warning);
}
let sql = protocol_sql(&manifest, dir);
let no_tokenizer = |error| Error::NoTokenizer {
which: engine.which(&info.printed),
error,
};
let installs = match check_extension_installs(&sql, info.version.as_ref(), engine) {
Err(refused @ Error::SqlNotRead { .. }) => {
return Err(engine.tokenizer_failure().map_or(refused, no_tokenizer));
}
Err(refused) => return Err(refused),
Ok(installs) if !installs.tokens.asked_duckdb() => {
if let Some(error) = engine.tokenizer_failure() {
return Err(no_tokenizer(error));
}
installs
}
Ok(installs) => installs,
};
for warning in &installs.warnings {
eprintln!("{} {}", "warning:".yellow(), warning);
}
if let Some(dev_build) = &info.dev_build
&& !installs.installed.is_empty()
{
eprintln!(
"{} this DuckDB build ({dev_build}) cannot download community extensions, and \
DuckDB's own release build can. {}",
"warning:".yellow(),
crate::engine::release_install_fix()
);
}
let (found, warnings) = check_extension_pins(
engine,
&installs.installed,
&manifest.extensions,
info.version.as_ref(),
)?;
for warning in &warnings {
eprintln!("{} {}", "warning:".yellow(), warning);
}
extensions = ExtensionRecheck::new(
installs,
found,
warnings,
&manifest.extensions,
info.version.as_ref(),
);
}
crate::introspect::check_reader().map_err(Error::SqlReader)?;
if manifest.steps.is_empty() {
println!("{}", "No steps defined.".dimmed());
return Ok(());
}
let asset_graph = AssetGraph::build(&manifest, dir);
if !asset_graph.unreadable.is_empty() {
let refusals: Vec<String> = asset_graph.unreadable.iter().map(|u| u.refusal()).collect();
return Err(Error::SqlReader(refusals.join("\n")));
}
let dotenv_vars = load_dotenv_files(dir, &manifest.dotenv);
let mut env_map = resolve_params(&manifest.params, &dotenv_vars, cli_params)?;
let db_path = manifest.db_path(dir)?;
if manifest.db.is_none() {
println!("{} {}", "database:".dimmed(), db_path.display());
}
env_map.insert(
crate::working_db::DB_PATH_ENV.to_string(),
db_path.display().to_string(),
);
state.init()?;
if let Some(current) = &engine_version
&& let Some(previous) = contract::newest_contract(&contract::runs_dir(dir))
&& let Some(notice) =
engine_change_notice(previous.run.engine.duckdb_cli.as_deref(), current)
{
for line in notice.lines() {
eprintln!("{} {}", "warning:".yellow(), line);
}
}
let run_id = state.start_run()?;
let all_produced = all_produced_assets(&asset_graph);
for warning in &asset_graph.warnings {
eprintln!("{} {}", "warning:".yellow(), warning);
}
asset_graph.validate_no_case_collisions()?;
if asset_graph.has_assets() {
let step_order: Vec<String> = manifest.steps.iter().map(|s| s.name.clone()).collect();
asset_graph.validate_order(&step_order)?;
}
let staleness = compute_staleness(
&manifest,
dir,
state,
&asset_graph,
&all_produced,
force,
&env_map,
)?;
let fetch_cache = crate::fetch_cache::FetchCache::from_env();
let total = manifest.steps.len();
let mut succeeded = 0;
let mut executed = 0;
let mut skipped = 0;
let mut total_retries: usize = 0;
let started_at = contract::now_iso();
let runs_dir = contract::runs_dir(dir);
let _ = std::fs::create_dir_all(&runs_dir);
let mut stream = contract::RunStream::create(&runs_dir, &run_id);
let mut step_outcomes: HashMap<String, contract::StepOutcome> = HashMap::new();
let contract_params = contract::param_entries(&manifest.params, &dotenv_vars, cli_params);
let pipeline_start = Instant::now();
let pipeline_timeout = manifest.timeout_sec.map(Duration::from_secs_f64);
let mut init_attempted = false;
if let Some(ref init_hook) = manifest.hooks.on_init {
init_attempted = true;
println!("{} {} ...", "[hook]".dimmed(), init_hook.name.bold());
if let Err(e) = execute_hook(
"on_init",
init_hook,
engine,
&mut extensions,
&db_path,
dir,
&env_map,
) {
eprintln!(
"{} on_init hook '{}' failed: {}",
"error:".red(),
init_hook.name,
e
);
if let Some(ref exit_hook) = manifest.hooks.on_exit {
let mut exit_env = env_map.clone();
exit_env.insert("ARC_PIPELINE_STATUS".to_string(), "init_failed".to_string());
println!("{} {} ...", "[hook]".dimmed(), exit_hook.name.bold());
if let Err(exit_err) = execute_hook(
"on_exit",
exit_hook,
engine,
&mut extensions,
&db_path,
dir,
&exit_env,
) {
report_hook_failure("on_exit", exit_hook, &exit_err);
}
}
let _ = state.finish_run(&run_id, executed, "init_failed", total_retries);
if let Err(changed) = recheck_extension_pins(extensions.pinned()) {
eprintln!("{} {}", "error:".red(), changed);
}
return Err(e);
}
}
let step_loop_result: std::result::Result<(), Error> = (|| {
for (i, step) in manifest.steps.iter().enumerate() {
let is_stale = staleness.stale.contains(&step.name);
if !is_stale {
let skip_reason = staleness.skip_reasons.get(&step.name).copied();
let reason_tag = skip_reason
.map(|r| format!("[skip: {}]", r.as_str()))
.unwrap_or_else(|| "[skip]".to_string());
println!(
"[{}/{}] {} {}",
i + 1,
total,
step.name.bold(),
reason_tag.dimmed(),
);
skipped += 1;
stream.step(&step.name, "skipped");
step_outcomes.insert(
step.name.clone(),
contract::StepOutcome {
state: "skipped".to_string(),
attempts: 0,
skip_reason,
duration_sec: None,
report: None,
},
);
continue;
}
if let Some(pt) = pipeline_timeout {
let elapsed = pipeline_start.elapsed();
if elapsed >= pt {
let _ = state.finish_run(&run_id, executed, "timeout", total_retries);
return Err(Error::PipelineTimeout {
step: step.name.clone(),
elapsed_sec: elapsed.as_secs_f64(),
});
}
}
println!("[{}/{}] {} ...", i + 1, total, step.name.bold());
let sql_hash = if let Some(ref sql) = step.sql {
let sql_path = dir.join(sql);
if !sql_path.exists() {
return Err(Error::SqlFileNotFound {
step: step.name.clone(),
path: sql_path,
});
}
let content = std::fs::read(&sql_path).map_err(|e| Error::FileRead {
path: sql_path.clone(),
source: e,
})?;
state::content_hash(&content)
} else if step.op.is_some() {
op_config_hash(step)
} else if step.command.is_some() {
command_config_hash(step)
} else {
String::new()
};
let capture_stdout = step.output.is_some();
let effective_retry = step
.retry
.as_ref()
.or_else(|| manifest.defaults.as_ref().and_then(|d| d.retry.as_ref()));
let max_attempts = effective_retry.map_or(1, |r| r.max_attempts);
let mut last_error = None;
let step_start = Instant::now();
for attempt in 1..=max_attempts {
if attempt > 1 {
if let Some(policy) = effective_retry {
let delay = backoff_duration(policy, attempt);
eprintln!(
"[retry {}/{}, backoff {:.1}s]",
attempt,
max_attempts,
delay.as_secs_f64()
);
std::thread::sleep(delay);
}
total_retries += 1;
}
let step_timeout = {
let step_t = step.timeout_sec.map(Duration::from_secs_f64);
if let Some(pt) = pipeline_timeout {
let remaining = pt.saturating_sub(pipeline_start.elapsed());
match step_t {
Some(st) => Some(st.min(remaining)),
None => Some(remaining),
}
} else {
step_t
}
};
let result = if let Some(source) = ProtocolSql::step(step, dir) {
check_again(&mut extensions, engine, &source).and_then(|()| {
engine.execute_sql(
&db_path,
&source.path,
&env_map,
step_timeout,
extensions.loaded_mut(),
)
})
} else if let Some(ref op_ref) = step.op {
match operator::resolve(op_ref) {
Ok(op) => {
let ctx = operator::OpContext {
dir,
db_path: db_path.as_path(),
env: &env_map,
timeout: step_timeout,
cache: fetch_cache.as_ref(),
};
let with = step.with.clone().unwrap_or(serde_yaml::Value::Null);
op.run(&with, &ctx)
}
Err(e) => Err(e),
}
} else if let Some(ref command) = step.command {
engine.execute_command(command, &env_map, capture_stdout, step_timeout)
} else {
unreachable!("validation ensures sql, command, or op is present")
};
match result {
Ok(output) => {
succeeded += 1;
executed += 1;
let artifact_hash = {
let unreadable = Unreadable::for_step(step);
let hash = produced_artifact_hash(
step,
dir,
&asset_graph,
&all_produced,
unreadable,
);
if hash.is_none() || unreadable == Unreadable::LeavesTheNameOut {
let missing = missing_declared_produces(step, dir, &asset_graph);
if !missing.is_empty() {
let consequence = match unreadable {
Unreadable::WithholdsTheHash => {
"arc will keep re-running this step until its own work \
(or the manifest's produces:) matches"
}
Unreadable::LeavesTheNameOut => {
"arc cannot tell whether that changed, so this step's \
skip rests on its preconditions and its command line \
alone"
}
};
eprintln!(
"{} step '{}' succeeded but does not appear to have produced: {} — {}",
"warning:".yellow(),
step.name,
missing.join(", "),
consequence
);
}
}
hash.unwrap_or_else(|| "MISSING".to_string())
};
let _ = state.record_step(
&step.name,
&sql_hash,
&artifact_hash,
StepStatus::Success,
);
precondition::record_all(&step.preconditions, dir, &step.name, &env_map);
if let Some(ref output_name) = step.output {
let captured = output.stdout.unwrap_or_default();
let env_key = format!("ARC_PARAM_{}", output_name.to_uppercase());
env_map.insert(env_key, captured);
}
last_error = None;
stream.step(&step.name, "success");
step_outcomes.insert(
step.name.clone(),
contract::StepOutcome {
state: "success".to_string(),
attempts: attempt,
skip_reason: None,
duration_sec: Some(step_start.elapsed().as_secs_f64()),
report: output.report,
},
);
break;
}
Err(Error::StepFailed { code, stderr, .. }) => {
last_error = Some(Error::StepFailed {
step: step.name.clone(),
code,
stderr,
});
}
Err(Error::StepTimeout {
step: timed_out_step,
}) => {
last_error = Some(Error::StepTimeout {
step: timed_out_step,
});
}
Err(e) => {
let _ = state.record_step(&step.name, &sql_hash, "", StepStatus::Failed);
let _ = state.finish_run(&run_id, executed, "error", total_retries);
stream.step(&step.name, "failed");
step_outcomes.insert(
step.name.clone(),
contract::StepOutcome {
state: "failed".to_string(),
attempts: attempt,
skip_reason: None,
duration_sec: Some(step_start.elapsed().as_secs_f64()),
report: None,
},
);
return Err(e);
}
}
}
if let Some(err) = last_error {
executed += 1;
let _ = state.record_step(&step.name, &sql_hash, "", StepStatus::Failed);
let _ = state.finish_run(&run_id, executed, "failed", total_retries);
stream.step(&step.name, "failed");
step_outcomes.insert(
step.name.clone(),
contract::StepOutcome {
state: "failed".to_string(),
attempts: max_attempts,
skip_reason: None,
duration_sec: Some(step_start.elapsed().as_secs_f64()),
report: None,
},
);
return Err(err);
}
}
let _ = state.finish_run(&run_id, executed, "success", total_retries);
Ok(())
})();
let mut step_loop_result = step_loop_result;
let mut refused_after_success = false;
match &step_loop_result {
Ok(()) => {
if let Some(ref success_hook) = manifest.hooks.on_success {
println!("{} {} ...", "[hook]".dimmed(), success_hook.name.bold());
if let Err(e) = execute_hook(
"on_success",
success_hook,
engine,
&mut extensions,
&db_path,
dir,
&env_map,
) {
if e.refused_before_it_ran() {
step_loop_result = Err(e);
refused_after_success = true;
} else {
report_hook_failure("on_success", success_hook, &e);
}
}
}
}
Err(e) => {
if let Some(ref failure_hook) = manifest.hooks.on_failure {
let mut failure_env = env_map.clone();
match e {
Error::StepFailed { step, code, .. } => {
failure_env.insert("ARC_FAILED_STEP".to_string(), step.clone());
failure_env.insert("ARC_EXIT_CODE".to_string(), code.to_string());
}
Error::StepTimeout { step } => {
failure_env.insert("ARC_FAILED_STEP".to_string(), step.clone());
failure_env.insert("ARC_EXIT_CODE".to_string(), "timeout".to_string());
}
Error::PipelineTimeout { step, .. } => {
failure_env.insert("ARC_FAILED_STEP".to_string(), step.clone());
failure_env.insert("ARC_EXIT_CODE".to_string(), "timeout".to_string());
}
Error::ExtensionRefusedBeforeItRan {
step: Some(step), ..
}
| Error::ExtensionPinRefusedBeforeItRan {
step: Some(step), ..
}
| Error::SqlNotRead {
step: Some(step), ..
} => {
failure_env.insert("ARC_FAILED_STEP".to_string(), step.clone());
failure_env.insert("ARC_EXIT_CODE".to_string(), "refused".to_string());
}
_ => {}
}
println!("{} {} ...", "[hook]".dimmed(), failure_hook.name.bold());
if let Err(hook_err) = execute_hook(
"on_failure",
failure_hook,
engine,
&mut extensions,
&db_path,
dir,
&failure_env,
) {
report_hook_failure("on_failure", failure_hook, &hook_err);
}
}
}
}
let should_run_exit = init_attempted || !manifest.steps.is_empty();
if should_run_exit && let Some(ref exit_hook) = manifest.hooks.on_exit {
let mut exit_env = env_map.clone();
match &step_loop_result {
Ok(()) => {
exit_env.insert("ARC_PIPELINE_STATUS".to_string(), "success".to_string());
}
Err(e) => {
exit_env.insert("ARC_PIPELINE_STATUS".to_string(), "failed".to_string());
match e {
Error::StepFailed { step, code, .. } => {
exit_env.insert("ARC_FAILED_STEP".to_string(), step.clone());
exit_env.insert("ARC_EXIT_CODE".to_string(), code.to_string());
}
Error::StepTimeout { step } => {
exit_env.insert("ARC_FAILED_STEP".to_string(), step.clone());
exit_env.insert("ARC_EXIT_CODE".to_string(), "timeout".to_string());
}
Error::PipelineTimeout { step, .. } => {
exit_env.insert("ARC_FAILED_STEP".to_string(), step.clone());
exit_env.insert("ARC_EXIT_CODE".to_string(), "timeout".to_string());
}
Error::ExtensionRefusedBeforeItRan {
step: Some(step), ..
}
| Error::ExtensionPinRefusedBeforeItRan {
step: Some(step), ..
}
| Error::SqlNotRead {
step: Some(step), ..
} => {
exit_env.insert("ARC_FAILED_STEP".to_string(), step.clone());
exit_env.insert("ARC_EXIT_CODE".to_string(), "refused".to_string());
}
_ => {}
}
}
}
println!("{} {} ...", "[hook]".dimmed(), exit_hook.name.bold());
if let Err(exit_err) = execute_hook(
"on_exit",
exit_hook,
engine,
&mut extensions,
&db_path,
dir,
&exit_env,
) {
if exit_err.refused_before_it_ran() && step_loop_result.is_ok() {
step_loop_result = Err(exit_err);
refused_after_success = true;
} else {
report_hook_failure("on_exit", exit_hook, &exit_err);
}
}
}
let (step_loop_result, changed_after_success) = match (
step_loop_result,
recheck_extension_pins(extensions.pinned()),
) {
(result, Ok(())) => (result, false),
(Ok(()), Err(changed)) => (Err(changed), true),
(Err(e), Err(changed)) => {
eprintln!("{} {}", "error:".red(), changed);
(Err(e), false)
}
};
let finished_at = contract::now_iso();
let outcome = match &step_loop_result {
Ok(()) => "success",
Err(_) if succeeded > 0 => "partial",
Err(_) => "error",
};
if changed_after_success || refused_after_success {
let _ = state.finish_run(&run_id, executed, outcome, total_retries);
}
let steps_engine = manifest.has_sql_steps().then(|| {
contract::steps_engine(
engine.report(extensions.recorded()),
extensions.recorded(),
extensions.loaded(),
)
});
let run_contract = contract::build_contract(contract::ContractInputs {
manifest: &manifest,
dir,
db_path: &db_path,
graph: &asset_graph,
run_id: &run_id,
started_at: &started_at,
finished_at: &finished_at,
outcome,
params: contract_params,
step_outcomes: &step_outcomes,
steps_engine,
possible_copies,
});
if let Err(e) = contract::write_contract(&runs_dir, &run_id, &run_contract) {
eprintln!(
"{} could not write run contract: {}",
"warning:".yellow(),
e
);
}
stream.complete(outcome);
print!("{}", contract::render_dag(&run_contract));
match step_loop_result {
Ok(()) => {
if skipped > 0 {
println!(
"\n{} {}/{} steps succeeded, {} skipped (fresh).",
"✓".green(),
succeeded,
total,
skipped,
);
} else {
println!("\n{} {}/{} steps succeeded.", "✓".green(), succeeded, total,);
}
Ok(())
}
Err(e) => Err(e),
}
}
struct Staleness {
stale: std::collections::HashSet<String>,
skip_reasons: HashMap<String, SkipReason>,
}
fn precondition_skip_reason(preconditions: &[Precondition]) -> SkipReason {
if preconditions
.iter()
.any(|p| matches!(p, Precondition::Tool { .. }))
{
SkipReason::PreconditionTool
} else if preconditions
.iter()
.any(|p| matches!(p, Precondition::ModifiedAfter { .. }))
{
SkipReason::PreconditionModifiedAfter
} else {
SkipReason::PreconditionFresh
}
}
fn compute_staleness(
manifest: &Manifest,
dir: &Path,
state: &dyn StateBackend,
asset_graph: &AssetGraph,
all_produced: &std::collections::HashSet<String>,
force: bool,
env: &HashMap<String, String>,
) -> Result<Staleness> {
let mut stale: std::collections::HashSet<String> = std::collections::HashSet::new();
if force {
for step in &manifest.steps {
stale.insert(step.name.clone());
}
return Ok(Staleness {
stale,
skip_reasons: HashMap::new(),
});
}
let mut reasons: HashMap<String, SkipReason> = HashMap::new();
for step in &manifest.steps {
if step.command.is_some() && step.preconditions.is_empty() {
stale.insert(step.name.clone());
continue;
}
let hash_stale = is_hash_stale(step, dir, state, asset_graph, all_produced)?;
if step.preconditions.is_empty() {
if hash_stale {
stale.insert(step.name.clone());
} else {
reasons.insert(step.name.clone(), SkipReason::HashClean);
}
} else {
let preconditions_fresh =
precondition::evaluate_all(&step.preconditions, dir, &step.name, env)?;
if hash_stale || !preconditions_fresh {
stale.insert(step.name.clone());
} else {
reasons.insert(
step.name.clone(),
precondition_skip_reason(&step.preconditions),
);
}
}
}
let directly_stale: Vec<String> = stale.iter().cloned().collect();
let downstream = asset_graph.downstream_steps(&directly_stale);
for step_name in downstream {
stale.insert(step_name);
}
reasons.retain(|name, _| !stale.contains(name));
Ok(Staleness {
stale,
skip_reasons: reasons,
})
}
fn command_config_hash(step: &crate::manifest::Step) -> String {
state::content_hash(step.command.as_deref().unwrap_or_default().as_bytes())
}
fn op_config_hash(step: &crate::manifest::Step) -> String {
let op = step.op.as_deref().unwrap_or_default();
let with = step
.with
.as_ref()
.and_then(|v| serde_yaml::to_string(v).ok())
.unwrap_or_default();
state::content_hash(format!("{}\n{}", op, with).as_bytes())
}
pub(crate) fn parse_params(raw: &[String]) -> Result<Vec<(String, String)>> {
let mut parsed = Vec::new();
for param in raw {
if let Some(pos) = param.find('=') {
let key = param[..pos].to_string();
let value = param[pos + 1..].to_string();
if key.is_empty() {
return Err(Error::ManifestValidation(format!(
"invalid --param '{}': key cannot be empty",
param
)));
}
parsed.push((key, value));
} else {
return Err(Error::ManifestValidation(format!(
"invalid --param '{}': expected KEY=VALUE format",
param
)));
}
}
Ok(parsed)
}
fn is_hash_stale(
step: &crate::manifest::Step,
dir: &Path,
state: &dyn StateBackend,
asset_graph: &AssetGraph,
all_produced: &std::collections::HashSet<String>,
) -> Result<bool> {
let prior = state.get_step_state(&step.name)?;
match prior {
None => Ok(step.command.is_none()),
Some(prior_state) => {
if prior_state.status == StepStatus::Failed {
return Ok(true); }
let config_stale = if step.op.is_some() {
op_config_hash(step) != prior_state.sql_hash
} else if step.command.is_some() {
command_config_hash(step) != prior_state.sql_hash
} else if let Some(ref sql) = step.sql {
let sql_path = dir.join(sql);
if sql_path.exists() {
let content = std::fs::read(&sql_path).map_err(|e| Error::FileRead {
path: sql_path.clone(),
source: e,
})?;
state::content_hash(&content) != prior_state.sql_hash
} else {
true }
} else {
false };
if config_stale {
return Ok(true);
}
match produced_artifact_hash(
step,
dir,
asset_graph,
all_produced,
Unreadable::for_step(step),
) {
None => Ok(true),
Some(current_artifact_hash) => {
Ok(current_artifact_hash != prior_state.artifact_hash)
}
}
}
}
}
fn all_produced_assets(asset_graph: &AssetGraph) -> std::collections::HashSet<String> {
asset_graph
.steps
.values()
.flat_map(|sa| sa.produces.iter().cloned())
.collect()
}
fn missing_declared_produces(
step: &crate::manifest::Step,
dir: &Path,
asset_graph: &AssetGraph,
) -> Vec<String> {
let Some(assets) = asset_graph.steps.get(&step.name) else {
return Vec::new();
};
assets
.produces
.iter()
.filter(|n| is_hashable_kind(assets, n))
.filter_map(|lowered| {
let raw = match assets.declared_case.get(lowered) {
Some(raws) if raws.len() == 1 => raws.iter().next().unwrap().as_str(),
_ => lowered.as_str(),
};
let full = dir.join(raw);
let is_missing = match declared_kind_of(assets, lowered) {
AssetKind::Directory => state::hash_directory_contents(&full).is_none(),
AssetKind::Pattern => hash_pattern_matches(dir, raw).is_none(),
_ => std::fs::read(&full).is_err(),
};
is_missing.then(|| raw.to_string())
})
.collect()
}
fn is_hashable_kind(assets: &StepAssets, name: &str) -> bool {
kind_is_hashed(declared_kind_of(assets, name))
}
fn kind_is_hashed(kind: AssetKind) -> bool {
match kind {
AssetKind::File | AssetKind::Directory | AssetKind::Pattern => true,
AssetKind::Table => false,
}
}
fn declared_kind_of(assets: &StepAssets, name: &str) -> AssetKind {
assets
.declared_kind
.get(name)
.copied()
.unwrap_or(AssetKind::File)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Unreadable {
WithholdsTheHash,
LeavesTheNameOut,
}
impl Unreadable {
fn for_step(step: &crate::manifest::Step) -> Self {
if step.command.is_some() {
Unreadable::LeavesTheNameOut
} else {
Unreadable::WithholdsTheHash
}
}
}
fn hash_pattern_matches(base: &Path, pattern: &str) -> Option<String> {
let components: Vec<&str> = pattern
.split(['/', '\\'])
.filter(|c| !c.is_empty() && *c != ".")
.collect();
if components.is_empty() {
return None;
}
let root: PathBuf = if pattern.starts_with('/') {
PathBuf::from("/")
} else {
base.to_path_buf()
};
let mut matches: Vec<PathBuf> = Vec::new();
expand_pattern(&root, &components, &mut matches)?;
matches.sort();
matches.dedup();
let mut buf = String::new();
for path in &matches {
let rel = path
.strip_prefix(&root)
.unwrap_or(path)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
let bytes = std::fs::read(path).ok()?;
buf.push_str(&rel);
buf.push('\u{1f}'); buf.push_str(&state::content_hash(&bytes));
buf.push('\n');
}
Some(state::content_hash(buf.as_bytes()))
}
fn expand_pattern(current: &Path, components: &[&str], out: &mut Vec<PathBuf>) -> Option<()> {
let Some((head, rest)) = components.split_first() else {
if current.is_file() {
out.push(current.to_path_buf());
}
return Some(());
};
if *head == "**" {
expand_pattern(current, rest, out)?;
for entry in std::fs::read_dir(current).ok()? {
let entry = entry.ok()?;
let path = entry.path();
let is_dir = entry.file_type().ok()?.is_dir();
if rest.is_empty() || is_dir {
expand_pattern(&path, rest, out)?;
}
if is_dir {
expand_pattern(&path, components, out)?;
}
}
return Some(());
}
if !head.contains(['*', '?', '[']) {
let child = current.join(head);
if rest.is_empty() {
if child.is_file() {
out.push(child);
}
return Some(());
}
if child.is_dir() {
return expand_pattern(&child, rest, out);
}
return Some(());
}
for entry in std::fs::read_dir(current).ok()? {
let entry = entry.ok()?;
let name = entry.file_name();
if !component_matches(head, &name.to_string_lossy()) {
continue;
}
if rest.is_empty() || entry.file_type().ok()?.is_dir() {
expand_pattern(&entry.path(), rest, out)?;
}
}
Some(())
}
fn component_matches(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
glob_match(&p, &n)
}
fn glob_match(p: &[char], n: &[char]) -> bool {
match p.first() {
None => n.is_empty(),
Some('*') => (0..=n.len()).any(|i| glob_match(&p[1..], &n[i..])),
Some('?') => !n.is_empty() && glob_match(&p[1..], &n[1..]),
Some('[') => {
let close = p.iter().skip(1).position(|c| *c == ']').map(|i| i + 1);
match close {
Some(close) if close > 1 => {
if n.is_empty() {
return false;
}
let body = &p[1..close];
let (negated, body) = match body.first() {
Some('!') | Some('^') => (true, &body[1..]),
_ => (false, body),
};
let mut hit = false;
let mut i = 0;
while i < body.len() {
if i + 2 < body.len() && body[i + 1] == '-' {
if body[i] <= n[0] && n[0] <= body[i + 2] {
hit = true;
}
i += 3;
} else {
if body[i] == n[0] {
hit = true;
}
i += 1;
}
}
hit != negated && glob_match(&p[close + 1..], &n[1..])
}
_ => !n.is_empty() && n[0] == '[' && glob_match(&p[1..], &n[1..]),
}
}
Some(c) => !n.is_empty() && n[0] == *c && glob_match(&p[1..], &n[1..]),
}
}
fn produced_artifact_hash(
step: &crate::manifest::Step,
dir: &Path,
asset_graph: &AssetGraph,
all_produced: &std::collections::HashSet<String>,
unreadable: Unreadable,
) -> Option<String> {
let step_assets = asset_graph.steps.get(&step.name);
let mut names: Vec<&String> = Vec::new();
if let Some(assets) = step_assets {
names.extend(
assets
.produces
.iter()
.filter(|n| is_hashable_kind(assets, n)),
);
names.extend(
assets
.reads
.iter()
.filter(|n| is_hashable_kind(assets, n) && !all_produced.contains(n.as_str())),
);
}
names.sort();
names.dedup();
let declared_case = step_assets.map(|a| &a.declared_case);
let mut buf = String::new();
for name in names {
let raw = match declared_case.and_then(|dc| dc.get(name)) {
Some(raws) if raws.len() == 1 => raws.iter().next().unwrap(),
Some(raws) if raws.len() > 1 => return None,
_ => name,
};
let full = dir.join(raw);
let kind = step_assets
.map(|a| declared_kind_of(a, name))
.unwrap_or(AssetKind::File);
let digest = match kind {
AssetKind::Directory => state::hash_directory_contents(&full),
AssetKind::Pattern => hash_pattern_matches(dir, raw),
_ => std::fs::read(&full).ok().map(|b| state::content_hash(&b)),
};
let Some(digest) = digest else {
match unreadable {
Unreadable::WithholdsTheHash => return None,
Unreadable::LeavesTheNameOut => continue,
}
};
buf.push_str(name);
buf.push('\u{1f}'); buf.push_str(&digest);
buf.push('\n');
}
Some(state::content_hash(buf.as_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::mock::{MockCall, MockEngine};
use crate::state::mock::MockStateBackend;
use std::fs;
fn setup_project(dir: &Path, yaml: &str, files: &[(&str, &str)]) {
fs::write(dir.join("arcform.yaml"), yaml).unwrap();
for (path, content) in files {
let full = dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(full, content).unwrap();
}
}
fn write_record(runs: &Path, name: &str, duckdb_cli: Option<&str>) {
let record = serde_json::json!({
"contract_version": "b4/1",
"run": {
"run_id": name, "attempt_id": name,
"protocol": {"name": "t", "manifest_sha256": null, "dir": "."},
"engine": {"arc": "0", "duckdb": null, "duckdb_cli": duckdb_cli,
"platform": null, "extensions": null},
"params": [], "started_at": null, "finished_at": null, "outcome": "success"
},
"assets": [], "steps": []
});
fs::create_dir_all(runs).unwrap();
fs::write(runs.join(format!("{name}.json")), record.to_string()).unwrap();
}
fn set_mtime(path: &Path, secs: u64) {
fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(std::time::UNIX_EPOCH + Duration::from_secs(secs))
.unwrap();
}
fn ver(s: &str) -> semver::Version {
semver::Version::parse(s).unwrap()
}
const ONE_SQL_STEP: &str = "name: t\nsteps:\n - name: s\n sql: s.sql\n";
#[test]
fn a_run_whose_tokens_read_asks_no_tokenizer_probe() {
let dir = tempfile::tempdir().unwrap();
setup_project(dir.path(), ONE_SQL_STEP, &[("s.sql", "SELECT 1;")]);
let engine = MockEngine::new();
run(dir.path(), &engine, &MockStateBackend::new(), false).unwrap();
assert_eq!(*engine.probed.borrow(), 0);
}
#[test]
fn a_duckdb_whose_tokenizer_does_not_answer_is_refused_before_a_step_runs() {
let dir = tempfile::tempdir().unwrap();
setup_project(dir.path(), ONE_SQL_STEP, &[("s.sql", "SELECT 1;")]);
let engine = MockEngine::new();
*engine.tokens_failure.borrow_mut() = Some("it exited with 1".into());
*engine.tokenizer_failure.borrow_mut() =
Some("Extension Autoloading Error: the extension 'autocomplete'".into());
let refused = run(dir.path(), &engine, &MockStateBackend::new(), false).unwrap_err();
assert!(matches!(refused, Error::NoTokenizer { .. }), "{refused:?}");
assert_eq!(refused.exit_code(), 2);
assert!(
!engine
.calls
.borrow()
.iter()
.any(|c| matches!(c, MockCall::Sql { .. })),
"a step ran"
);
let message = refused.to_string();
assert!(
message.starts_with("the mock DuckDB cannot read SQL into tokens"),
"{message}"
);
assert!(!message.contains("could not read the tokens"), "{message}");
}
#[test]
fn a_tokenizer_failing_for_another_reason_is_refused_quoting_duckdb() {
let dir = tempfile::tempdir().unwrap();
setup_project(dir.path(), ONE_SQL_STEP, &[("s.sql", "SELECT 1;")]);
let engine = MockEngine::new();
*engine.tokens_failure.borrow_mut() = Some("it exited with 1".into());
*engine.tokenizer_failure.borrow_mut() = Some("Out of Memory Error: no room".into());
let refused = run(dir.path(), &engine, &MockStateBackend::new(), false).unwrap_err();
assert!(matches!(refused, Error::NoTokenizer { .. }), "{refused:?}");
let message = refused.to_string();
assert!(
message.contains("said \"Out of Memory Error: no room\""),
"{message}"
);
assert!(!message.contains("built without"), "{message}");
assert!(message.contains("DUCKDB_VERSION=2.0.0"), "{message}");
}
#[test]
fn a_duckdb_whose_tokenizer_answers_keeps_the_refusal_of_the_file() {
let dir = tempfile::tempdir().unwrap();
setup_project(dir.path(), ONE_SQL_STEP, &[("s.sql", "SELECT 1;")]);
let engine = MockEngine::new();
*engine.tokens_failure.borrow_mut() = Some("it printed nothing".into());
let refused = run(dir.path(), &engine, &MockStateBackend::new(), false).unwrap_err();
assert!(matches!(refused, Error::SqlNotRead { .. }), "{refused:?}");
assert_eq!(*engine.probed.borrow(), 1);
}
#[test]
fn a_run_with_no_sql_to_ask_about_probes_the_tokenizer_once() {
let dir = tempfile::tempdir().unwrap();
setup_project(dir.path(), ONE_SQL_STEP, &[("s.sql", "")]);
let engine = MockEngine::new();
*engine.tokenizer_failure.borrow_mut() =
Some("Extension Autoloading Error: the extension 'autocomplete'".into());
let refused = run(dir.path(), &engine, &MockStateBackend::new(), false).unwrap_err();
assert!(matches!(refused, Error::NoTokenizer { .. }), "{refused:?}");
assert_eq!(*engine.probed.borrow(), 1);
}
#[test]
fn no_record_no_notice() {
assert_eq!(engine_change_notice(None, &ver("2.0.0")), None);
}
#[test]
fn same_minor_no_notice() {
assert_eq!(engine_change_notice(Some("v2.0.1"), &ver("2.0.3")), None);
}
#[test]
fn one_five_to_two_names_the_four_changes() {
let n = engine_change_notice(Some("v1.5.6"), &ver("2.0.0")).unwrap();
assert!(
n.contains("1.5.6") && n.contains("2.0.0") && n.contains("ORDER BY"),
"{n}"
);
assert!(n.starts_with(
"this Protocol last ran on DuckDB 1.5.6, and this run uses DuckDB 2.0.0; results can differ between them\n"
));
}
#[test]
fn two_zero_to_two_one_has_no_four_changes_line() {
let n = engine_change_notice(Some("v2.0.0"), &ver("2.1.0")).unwrap();
assert!(!n.contains("four results") && !n.contains('\n'), "{n}");
}
#[test]
fn an_unparseable_previous_version_draws_no_notice() {
assert_eq!(
engine_change_notice(Some("not a version"), &ver("2.0.0")),
None
);
}
#[test]
fn a_dev_build_of_a_release_compares_as_that_release() {
let dev = "v2.0.0-dev88614 (Development Version) d54cbbe3df";
let parsed = crate::engine::parse_engine_version(dev).unwrap().version;
assert_eq!(engine_change_notice(Some("v2.0.0"), &parsed), None);
assert_eq!(engine_change_notice(Some(dev), &ver("2.0.0")), None);
}
#[test]
fn a_truncated_newest_record_falls_back_to_an_older_1_x_record_and_notices() {
let dir = tempfile::tempdir().unwrap();
let runs = contract::runs_dir(dir.path());
write_record(&runs, "old", Some("v1.5.6"));
fs::write(
runs.join("new.json"),
"{\"contract_version\": \"b4/1\", \"run\": {",
)
.unwrap();
set_mtime(&runs.join("old.json"), 1_000);
set_mtime(&runs.join("new.json"), 2_000);
let previous = contract::newest_contract(&runs).unwrap();
let n = engine_change_notice(previous.run.engine.duckdb_cli.as_deref(), &ver("2.0.0"));
assert!(n.unwrap().contains("1.5.6"));
}
#[test]
fn test_run_empty_steps() {
let dir = tempfile::tempdir().unwrap();
setup_project(dir.path(), "name: test\nsteps: []\n", &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert!(engine.calls.borrow().is_empty());
}
#[test]
fn a_step_duckdbs_answer_cannot_be_read_for_refuses_the_run() {
use crate::duckdb_lineage::Refusal;
use crate::introspect::test_reading;
let dir = tempfile::tempdir().unwrap();
setup_project(
dir.path(),
"name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n",
&[("models/s1.sql", "CREATE TABLE t AS SELECT 1;")],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
let err = test_reading::with(
|_| Err(Refusal::Unreadable("binder: m".to_string())),
|| run(dir.path(), &engine, &state, false),
)
.expect_err("the run is refused");
let message = err.to_string();
assert!(
matches!(err, Error::SqlReader(_))
&& message.contains("step 's1' (models/s1.sql)")
&& message.contains("binder: m"),
"{message}"
);
assert!(
!engine
.calls
.borrow()
.iter()
.any(|call| matches!(call, crate::engine::mock::MockCall::Sql { .. })),
"no step ran: {:?}",
engine.calls.borrow()
);
assert!(!*state.init_called.borrow(), "the state backend was set up");
assert!(state.runs.borrow().is_empty(), "a run was started");
}
#[test]
fn test_run_sql_steps_in_order() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n - name: s2\n sql: models/s2.sql\n - name: s3\n sql: models/s3.sql\n";
setup_project(
dir.path(),
yaml,
&[
("models/s1.sql", "CREATE TABLE t(v TEXT);"),
("models/s2.sql", "INSERT INTO t VALUES ('b');"),
("models/s3.sql", "INSERT INTO t VALUES ('c');"),
],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 4); assert!(matches!(calls[0], MockCall::Preflight));
let sql_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Sql { sql_content, .. } => Some(sql_content.as_str()),
_ => None,
})
.collect();
assert_eq!(
sql_calls,
vec![
"CREATE TABLE t(v TEXT);",
"INSERT INTO t VALUES ('b');",
"INSERT INTO t VALUES ('c');",
]
);
}
#[test]
fn test_run_command_step() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n";
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 1);
assert!(matches!(&calls[0], MockCall::Command { command, .. } if command == "echo hello"));
}
#[test]
fn test_run_halts_on_step2_failure_step3_skipped() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n - name: s2\n sql: models/s2.sql\n - name: s3\n sql: models/s3.sql\n";
setup_project(
dir.path(),
yaml,
&[
("models/s1.sql", "SELECT 1;"),
("models/s2.sql", "INVALID SQL;"),
("models/s3.sql", "SELECT 3;"),
],
);
let engine = MockEngine::new();
engine.set_fail_on_call(1, 1, "syntax error");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("s2"),
"error should name step 's2': {err_msg}"
);
}
#[test]
fn test_run_missing_sql_file() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/missing.sql\n";
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
assert!(err.to_string().contains("sql file not found"));
}
#[test]
fn test_sql_content_passed_unmodified() {
let dir = tempfile::tempdir().unwrap();
let original_sql = "SELECT 1;\n-- comment with special chars: émojis 🎉\n";
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", original_sql)]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_content = match &calls[1] {
MockCall::Sql { sql_content, .. } => sql_content.as_str(),
_ => panic!("expected Sql call"),
};
assert_eq!(sql_content, original_sql);
}
#[test]
fn test_preflight_failure_blocks_execution() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
engine.set_preflight_failure();
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
assert!(
err.to_string().contains("not found"),
"should report engine not found: {err}"
);
}
#[test]
fn test_command_step_failure_halts_pipeline() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: fetch\n command: curl http://example.com\n - name: transform\n command: echo done\n";
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_fail_on_call(0, 1, "connection refused");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("fetch"),
"error should name step 'fetch': {err_msg}"
);
}
#[test]
fn test_v02_dependency_order_blocks_execution() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: summary\n sql: models/summary.sql\n - name: load\n sql: models/load.sql\n";
setup_project(
dir.path(),
yaml,
&[
(
"models/summary.sql",
"CREATE TABLE summary AS SELECT count(*) FROM customers;",
),
("models/load.sql", "CREATE TABLE customers (id INT);"),
],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("summary"),
"error should name reader 'summary': {err_msg}"
);
assert!(
err_msg.contains("customers"),
"error should name asset 'customers': {err_msg}"
);
}
#[test]
fn test_v02_v1_manifest_runs_unchanged() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n - name: done\n command: echo done\n";
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 2);
}
#[test]
fn test_v02_unparseable_sql_still_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: weird\n sql: models/weird.sql\n";
setup_project(
dir.path(),
yaml,
&[("models/weird.sql", "THIS IS NOT VALID SQL %%%")],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 2); }
#[test]
fn test_v02_valid_chain_succeeds() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: step-a\n sql: models/a.sql\n - name: step-b\n sql: models/b.sql\n - name: step-c\n sql: models/c.sql\n";
setup_project(
dir.path(),
yaml,
&[
("models/a.sql", "CREATE TABLE x (id INT);"),
("models/b.sql", "CREATE TABLE y AS SELECT * FROM x;"),
("models/c.sql", "CREATE TABLE z AS SELECT * FROM y;"),
],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 4); }
#[test]
fn test_v03_fresh_step_skipped() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
let sql = "CREATE TABLE t(v TEXT);";
setup_project(dir.path(), yaml, &[("models/s1.sql", sql)]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls_after_first = engine.calls.borrow().len();
assert_eq!(calls_after_first, 2);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 1, "fresh step should be skipped on second run");
assert!(matches!(calls[0], MockCall::Preflight));
}
#[test]
fn test_v03_stale_step_reruns() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::write(dir.path().join("models/s1.sql"), "SELECT 2;").unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(
calls.len(),
2,
"stale step should re-run: preflight + 1 sql"
);
}
#[test]
fn test_produced_file_change_forces_rerun() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/out.csv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/out.csv"), "a,b\n1,2\n").unwrap();
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"first run: preflight + 1 sql"
);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"unchanged produced file should stay skipped (preflight only)"
);
fs::write(dir.path().join("build/out.csv"), "a,b\n1,2\n3,4\n").unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"edited produced file must force a re-run: preflight + 1 sql"
);
}
#[test]
fn test_produced_file_truncated_forces_rerun() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/out.csv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/out.csv"), "a,b\n1,2\n").unwrap();
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::write(dir.path().join("build/out.csv"), "").unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"truncated produced file must force a re-run: preflight + 1 sql"
);
}
#[test]
fn test_produced_file_deleted_forces_rerun() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/out.csv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/out.csv"), "a,b\n1,2\n").unwrap();
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::remove_file(dir.path().join("build/out.csv")).unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"deleted produced file must force a re-run: preflight + 1 sql"
);
}
#[test]
fn test_case_mismatched_produced_file_change_forces_rerun() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/REGISTRANT.tsv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/REGISTRANT.tsv"), "cik\tlei\n1\tX\n").unwrap();
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"first run: preflight + 1 sql"
);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"unchanged case-mismatched file should stay skipped (preflight only)"
);
fs::write(
dir.path().join("build/REGISTRANT.tsv"),
"cik\tlei\n1\tX\n2\tY\n",
)
.unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"edited case-mismatched produced file must force a re-run: preflight + 1 sql"
);
}
#[test]
fn test_case_mismatched_produced_file_truncated_forces_rerun() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/REGISTRANT.tsv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/REGISTRANT.tsv"), "cik\tlei\n1\tX\n").unwrap();
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::write(dir.path().join("build/REGISTRANT.tsv"), "").unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"truncated case-mismatched produced file must force a re-run: preflight + 1 sql"
);
}
#[test]
fn test_case_mismatched_produced_file_deleted_forces_rerun() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/REGISTRANT.tsv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/REGISTRANT.tsv"), "cik\tlei\n1\tX\n").unwrap();
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::remove_file(dir.path().join("build/REGISTRANT.tsv")).unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"deleted case-mismatched produced file must force a re-run: preflight + 1 sql"
);
}
#[test]
fn test_produces_case_collision_refused_at_load() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/Report.csv]\n - name: s2\n sql: models/s2.sql\n produces: [build/report.csv]\n";
setup_project(
dir.path(),
yaml,
&[
("models/s1.sql", "SELECT 1;"),
("models/s2.sql", "SELECT 2;"),
],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("collision") && msg.contains("case"),
"expected a case-collision refusal, got: {msg}"
);
assert!(
matches!(engine.calls.borrow().as_slice(), [MockCall::Preflight]),
"a refused manifest must not execute any step: {:?}",
engine.calls.borrow()
);
}
#[test]
fn test_produces_case_collision_is_refused_when_one_reader_matches_one_spelling() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/Report.csv]\n - name: s2\n sql: models/s2.sql\n produces: [build/report.csv]\n - name: s3\n sql: models/s3.sql\n depends_on: [build/report.csv]\n";
setup_project(
dir.path(),
yaml,
&[
("models/s1.sql", "SELECT 1;"),
("models/s2.sql", "SELECT 2;"),
("models/s3.sql", "SELECT 3;"),
],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("collision") && msg.contains("case"),
"expected a case-collision refusal, got: {msg}"
);
assert!(
msg.contains("build/Report.csv") && msg.contains("build/report.csv"),
"the refusal must name both producer spellings, got: {msg}"
);
assert!(
matches!(engine.calls.borrow().as_slice(), [MockCall::Preflight]),
"a refused manifest must not execute any step: {:?}",
engine.calls.borrow()
);
}
#[test]
fn test_declared_artifact_deletion_is_caught_even_with_a_surviving_decoy() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/REGISTRANT.tsv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/REGISTRANT.tsv"), "real content\n").unwrap();
fs::write(dir.path().join("build/registrant.tsv"), "decoy content\n").unwrap();
if fs::read_dir(dir.path().join("build")).unwrap().count() < 2 {
eprintln!(
"skipping test_declared_artifact_deletion_is_caught_even_with_a_surviving_decoy: \
{} does not distinguish case, so a genuine collision cannot be constructed here",
dir.path().display()
);
return;
}
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(engine.calls.borrow().len(), 2, "run 1: preflight + 1 sql");
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"run 2: unchanged declared file must stay skipped despite the decoy"
);
fs::remove_file(dir.path().join("build/REGISTRANT.tsv")).unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"run 3: deleting the declared artifact must force a re-run even though a \
differently-cased file survives it: preflight + 1 sql"
);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"run 4: a declared file that is STILL absent must force another re-run, \
not certify itself unchanged against its own prior absence: \
preflight + 1 sql"
);
}
#[test]
fn test_declared_case_that_never_existed_forces_perpetual_staleness() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/REGISTRANT.tsv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(
dir.path().join("build/registrant.tsv"),
"leftover content\n",
)
.unwrap();
if dir.path().join("build/REGISTRANT.tsv").exists() {
eprintln!(
"skipping test_declared_case_that_never_existed_forces_perpetual_staleness: \
{} does not distinguish case, so the declared name and the leftover are the same file",
dir.path().display()
);
return;
}
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"run 2: declared file still absent, must force a re-run rather than \
certify unchanged: preflight + 1 sql"
);
fs::write(
dir.path().join("build/registrant.tsv"),
"mutated leftover\n",
)
.unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"run 3: still absent, still must force a re-run — an artifact that has \
never existed cannot become 'stably unchanged'"
);
}
#[test]
fn test_case_sensitivity_probe_agrees_with_observed_runner_behavior() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/REGISTRANT.tsv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/REGISTRANT.tsv"), "declared\n").unwrap();
fs::write(dir.path().join("build/registrant.tsv"), "decoy\n").unwrap();
let case_sensitive = fs::read_dir(dir.path().join("build")).unwrap().count() == 2;
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::write(dir.path().join("build/registrant.tsv"), "mutated decoy\n").unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
let mutation_detected = engine.calls.borrow().len() > 1;
assert_eq!(
case_sensitive,
!mutation_detected,
"incoherent habitat: case_sensitive={case_sensitive}, \
mutation_detected={mutation_detected} in {} — a case-sensitive habitat \
must ignore a decoy's mutation, and a folding one cannot have a separate \
decoy to ignore at all",
dir.path().display()
);
}
#[test]
fn test_depends_on_case_collision_refused_at_load() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n depends_on: [build/DATA.csv, build/data.csv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("collision") && msg.contains("depends_on:"),
"expected a depends_on: case-collision refusal naming the manifest key, \
got: {msg}"
);
assert!(
matches!(engine.calls.borrow().as_slice(), [MockCall::Preflight]),
"a refused manifest must not execute any step: {:?}",
engine.calls.borrow()
);
}
#[test]
fn test_ambiguous_declared_case_forces_staleness_not_iteration_order_pick() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n depends_on: [build/data.csv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/DATA.csv"), "from DATA.csv\n").unwrap();
fs::write(dir.path().join("build/data.csv"), "from data.csv\n").unwrap();
if fs::read_dir(dir.path().join("build")).unwrap().count() < 2 {
eprintln!(
"skipping test_ambiguous_declared_case_forces_staleness_not_iteration_order_pick: \
{} does not distinguish case, so a genuine collision cannot be constructed here",
dir.path().display()
);
return;
}
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
let mut asset_graph = AssetGraph::build(&manifest, dir.path());
asset_graph
.steps
.get_mut("s1")
.unwrap()
.declared_case
.get_mut("build/data.csv")
.unwrap()
.insert("build/DATA.csv".to_string());
let step = manifest.steps.iter().find(|s| s.name == "s1").unwrap();
let all_produced = all_produced_assets(&asset_graph);
let result = produced_artifact_hash(
step,
dir.path(),
&asset_graph,
&all_produced,
Unreadable::for_step(step),
);
assert_eq!(
result, None,
"an ambiguous declared_case entry (2+ raw spellings) must never be \
resolved by picking one — it must force staleness (None), not silently \
choose build/DATA.csv over build/data.csv by BTreeSet iteration order"
);
}
#[test]
fn test_external_unproduced_file_change_forces_rerun() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n depends_on: [external.csv]\n";
setup_project(
dir.path(),
yaml,
&[
("models/s1.sql", "SELECT 1;"),
("external.csv", "a,b\n1,2\n"),
],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"first run: preflight + 1 sql"
);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"unchanged external input should stay skipped (preflight only)"
);
fs::write(dir.path().join("external.csv"), "a,b\n1,2\n3,4\n").unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"changed external input must force a re-run: preflight + 1 sql"
);
}
#[test]
fn test_v03_downstream_propagation() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: step-a\n sql: models/a.sql\n - name: step-b\n sql: models/b.sql\n - name: step-c\n sql: models/c.sql\n";
setup_project(
dir.path(),
yaml,
&[
("models/a.sql", "CREATE TABLE x (id INT);"),
("models/b.sql", "CREATE TABLE y AS SELECT * FROM x;"),
("models/c.sql", "CREATE TABLE z AS SELECT * FROM y;"),
],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::write(
dir.path().join("models/a.sql"),
"CREATE TABLE x (id INT, name TEXT);",
)
.unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
3,
"all 3 steps should re-run due to downstream propagation"
);
}
#[test]
fn test_v03_failed_step_reruns() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
engine.set_fail_on_call(0, 1, "error");
let state = MockStateBackend::new();
let _ = run(dir.path(), &engine, &state, false);
let step_state = state.get_step_state("s1").unwrap().unwrap();
assert_eq!(step_state.status, StepStatus::Failed);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(sql_calls.len(), 1, "failed step should re-run");
}
#[test]
fn test_v03_command_always_reruns() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n";
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 1, "command step should always re-run");
}
#[test]
fn test_v03_force_runs_all() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
{
let engine = MockEngine::new();
run(dir.path(), &engine, &state, true).unwrap();
let calls = engine.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(sql_calls.len(), 1, "--force should run fresh step");
}
{
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(sql_calls.len(), 0, "after --force, step should be fresh");
}
}
#[test]
fn test_v03_first_run_all_stale() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n - name: s2\n sql: models/s2.sql\n";
setup_project(
dir.path(),
yaml,
&[
("models/s1.sql", "SELECT 1;"),
("models/s2.sql", "SELECT 2;"),
],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(sql_calls.len(), 2, "first run should execute all steps");
}
#[test]
fn test_lrp_version_mismatch_blocks_execution() {
let dir = tempfile::tempdir().unwrap();
let yaml =
"name: test\nengine_version: '>=2.1'\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
engine.set_version(Some(semver::Version::new(2, 0, 0)));
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err(), "should fail due to version mismatch");
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 1, "only preflight should be called");
assert!(matches!(calls[0], MockCall::Preflight));
}
#[test]
fn test_lrp_error_contains_both_versions() {
let dir = tempfile::tempdir().unwrap();
let yaml =
"name: test\nengine_version: '>=2.1'\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
engine.set_version(Some(semver::Version::new(2, 0, 0)));
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains(">=2.1"),
"error should contain constraint: {msg}"
);
assert!(
msg.contains("2.0.0"),
"error should contain detected version: {msg}"
);
}
#[test]
fn test_lrp_no_version_constraint_still_meets_the_supported_range() {
for (major, minor, patch) in [(3, 0, 0), (1, 5, 6)] {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
engine.set_version(Some(semver::Version::new(major, minor, patch)));
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
assert!(
matches!(
err,
Error::UntestedEngine { .. } | Error::EngineTooOld { .. }
),
"{major}.{minor}.{patch}: expected a refusal by arc's range, got {err:?}"
);
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 1, "only preflight should be called");
assert!(matches!(calls[0], MockCall::Preflight));
}
}
#[test]
fn test_lrp_no_version_constraint_in_range_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
engine.set_version(Some(semver::Version::new(2, 0, 0)));
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert!(calls.iter().any(|c| matches!(c, MockCall::Sql { .. })));
}
fn v(s: &str) -> semver::Version {
semver::Version::parse(s).unwrap()
}
#[test]
fn test_check_engine_version_range_bounds() {
for ok in ["2.0.0", "2.0.1", "2.99.99"] {
assert!(
check_engine_version(Some(&v(ok)), None, false)
.unwrap()
.is_empty(),
"{ok} is in range and must pass without a warning"
);
}
for older in ["1.5.6", "1.99.99", "1.2.0"] {
let err = check_engine_version(Some(&v(older)), None, false).unwrap_err();
assert!(
matches!(&err, Error::EngineTooOld { found, oldest, .. }
if found == older && *oldest == OLDEST_ENGINE),
"{older}: expected EngineTooOld, got {err:?}"
);
}
for refused in ["3.0.0", "3.1.0", "2.0.0-dev1"] {
let err = check_engine_version(Some(&v(refused)), None, false).unwrap_err();
assert!(
matches!(err, Error::UntestedEngine { .. }),
"{refused}: expected UntestedEngine, got {err:?}"
);
}
}
#[test]
fn test_supported_range_starts_at_the_oldest_engine() {
let range = semver::VersionReq::parse(SUPPORTED_ENGINE_RANGE).unwrap();
assert!(range.matches(&v(OLDEST_ENGINE)), "{SUPPORTED_ENGINE_RANGE}");
assert!(!range.matches(&v("1.99.99")), "{SUPPORTED_ENGINE_RANGE}");
assert_eq!(OLDEST_ENGINE, "2.0.0");
}
#[test]
fn test_check_engine_version_override_does_not_reach_an_older_engine() {
for older in ["1.5.6", "1.2.0", "1.1.3"] {
let err = check_engine_version(Some(&v(older)), None, true).unwrap_err();
assert!(
matches!(&err, Error::EngineTooOld { found, .. } if found == older),
"{older}: expected EngineTooOld, got {err:?}"
);
assert!(err.to_string().contains("0.2.1"), "{err}");
}
for not_older in ["3.0.0", "2.0.0-dev1"] {
let warnings = check_engine_version(Some(&v(not_older)), None, true).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains(not_older), "{warnings:?}");
}
}
#[test]
fn test_check_engine_version_manifest_below_the_oldest_does_not_widen() {
let err = check_engine_version(Some(&v("1.5.6")), Some(">=1.0"), false).unwrap_err();
assert!(matches!(err, Error::EngineTooOld { .. }), "got {err:?}");
assert!(
check_engine_version(Some(&v("2.0.0")), Some(">=1.0"), false)
.unwrap()
.is_empty()
);
}
#[test]
fn test_check_engine_version_pre_release_is_untested() {
let dev = v("2.1.0-dev123");
let err = check_engine_version(Some(&dev), None, false).unwrap_err();
assert!(matches!(err, Error::UntestedEngine { .. }), "got {err:?}");
let warnings = check_engine_version(Some(&dev), None, true).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("2.1.0-dev123"), "{warnings:?}");
}
#[test]
fn test_check_engine_version_manifest_constraint_survives_the_override() {
for allow in [false, true] {
let err = check_engine_version(Some(&v("2.0.0")), Some(">=2.1"), allow).unwrap_err();
assert!(
matches!(&err, Error::VersionMismatch { required, found }
if required == ">=2.1" && found == "2.0.0"),
"allow={allow}: got {err:?}"
);
}
let err = check_engine_version(Some(&v("3.0.0")), Some("<3"), true).unwrap_err();
assert!(matches!(err, Error::VersionMismatch { .. }), "got {err:?}");
}
#[test]
fn test_check_engine_version_unparseable_warns_naming_both_constraints() {
let warnings = check_engine_version(None, Some(">=1.5"), false).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains(">=1.5"), "{warnings:?}");
assert!(warnings[0].contains(SUPPORTED_ENGINE_RANGE), "{warnings:?}");
let warnings = check_engine_version(None, None, false).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains(SUPPORTED_ENGINE_RANGE), "{warnings:?}");
}
#[test]
fn test_lrp_version_satisfies_constraint() {
let dir = tempfile::tempdir().unwrap();
let yaml =
"name: test\nengine_version: '>=2.0'\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
engine.set_version(Some(semver::Version::new(2, 0, 2)));
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
}
#[test]
fn test_lrp_unparseable_version_warns_continues() {
let dir = tempfile::tempdir().unwrap();
let yaml =
"name: test\nengine_version: '>=1.5'\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
engine.set_version(None);
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
1,
"step should execute despite unparseable version"
);
}
#[test]
fn test_pre_preconditions_deserialise() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: fetch
command: "curl http://example.com"
preconditions:
- modified_after:
path: data/output.json
period: 24h
- command: "test -f /tmp/ready"
"#;
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
assert_eq!(manifest.steps[0].preconditions.len(), 2);
}
#[test]
fn test_pre_no_preconditions_backwards_compat() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n";
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
assert!(manifest.steps[0].preconditions.is_empty());
}
#[test]
fn test_pre_command_with_fresh_precondition_skipped() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: fetch
command: "echo fetching"
preconditions:
- command: "true"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert!(
calls.is_empty(),
"command step with fresh precondition should be skipped, got {} calls",
calls.len()
);
}
#[test]
fn test_pre_command_with_stale_precondition_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: fetch
command: "echo fetching"
preconditions:
- command: "false"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(
calls.len(),
1,
"command step with stale precondition should run"
);
assert!(
matches!(&calls[0], MockCall::Command { command, .. } if command == "echo fetching")
);
}
#[test]
fn test_pre_command_no_preconditions_always_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n";
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let engine2 = MockEngine::new();
run(dir.path(), &engine2, &state, false).unwrap();
let calls = engine2.calls.borrow();
assert_eq!(
calls.len(),
1,
"command step without preconditions should always re-run"
);
}
#[test]
fn test_pre_sql_fresh_hash_stale_precondition_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: transform
sql: models/transform.sql
preconditions:
- command: "false"
"#;
setup_project(dir.path(), yaml, &[("models/transform.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let engine2 = MockEngine::new();
run(dir.path(), &engine2, &state, false).unwrap();
let calls = engine2.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
1,
"SQL step should run when precondition is stale"
);
}
#[test]
fn test_pre_sql_stale_hash_fresh_precondition_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: transform
sql: models/transform.sql
preconditions:
- command: "true"
"#;
setup_project(dir.path(), yaml, &[("models/transform.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::write(dir.path().join("models/transform.sql"), "SELECT 2;").unwrap();
let engine2 = MockEngine::new();
run(dir.path(), &engine2, &state, false).unwrap();
let calls = engine2.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
1,
"SQL step should run when hash is stale (AND semantics)"
);
}
#[test]
fn test_pre_sql_both_fresh_skips() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: transform
sql: models/transform.sql
preconditions:
- command: "true"
"#;
setup_project(dir.path(), yaml, &[("models/transform.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let engine2 = MockEngine::new();
run(dir.path(), &engine2, &state, false).unwrap();
let calls = engine2.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
0,
"SQL step should be skipped when both hash and precondition are fresh"
);
}
#[test]
fn test_pre_sql_both_stale_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: transform
sql: models/transform.sql
preconditions:
- command: "false"
"#;
setup_project(dir.path(), yaml, &[("models/transform.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
fs::write(dir.path().join("models/transform.sql"), "SELECT 2;").unwrap();
let engine2 = MockEngine::new();
run(dir.path(), &engine2, &state, false).unwrap();
let calls = engine2.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
1,
"SQL step should run when both hash and precondition are stale"
);
}
#[test]
fn test_pre_sql_no_preconditions_uses_hash() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let engine2 = MockEngine::new();
run(dir.path(), &engine2, &state, false).unwrap();
let calls = engine2.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
0,
"SQL step without preconditions should use hash staleness"
);
}
#[test]
fn test_pre_force_overrides_preconditions() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: fetch
command: "echo fetching"
preconditions:
- command: "true"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, true).unwrap();
let calls = engine.calls.borrow();
assert_eq!(
calls.len(),
1,
"--force should override fresh precondition and run the step"
);
assert!(
matches!(&calls[0], MockCall::Command { command, .. } if command == "echo fetching")
);
}
#[test]
fn test_pre_manifest_rejects_invalid_precondition() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: fetch
command: "echo fetching"
preconditions:
- modified_after:
path: data/file.json
period: "banana"
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("banana"),
"error should mention the bad duration: {msg}"
);
}
#[test]
fn test_param_manifest_with_params_deserialises() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
params:
start_date:
default: "2026-01-01"
end_date: {}
dotenv:
- .env
- .env.local
steps:
- name: greet
command: echo hello
"#;
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
assert_eq!(manifest.params.len(), 2);
assert_eq!(
manifest.params["start_date"].default,
Some("2026-01-01".to_string())
);
assert!(manifest.params["end_date"].default.is_none());
assert_eq!(manifest.dotenv, vec![".env", ".env.local"]);
}
#[test]
fn test_param_manifest_without_params_empty_defaults() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n";
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
assert!(manifest.params.is_empty());
assert!(manifest.dotenv.is_empty());
}
#[test]
fn test_param_parse_valid_params() {
let raw = vec![
"start_date=2026-01-01".to_string(),
"region=us-east-1".to_string(),
];
let parsed = parse_params(&raw).unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(
parsed[0],
("start_date".to_string(), "2026-01-01".to_string())
);
assert_eq!(parsed[1], ("region".to_string(), "us-east-1".to_string()));
}
#[test]
fn test_param_parse_value_with_equals() {
let raw = vec!["query=SELECT * FROM t WHERE x=1".to_string()];
let parsed = parse_params(&raw).unwrap();
assert_eq!(parsed[0].0, "query");
assert_eq!(parsed[0].1, "SELECT * FROM t WHERE x=1");
}
#[test]
fn test_param_parse_invalid_no_equals() {
let raw = vec!["no_equals_here".to_string()];
let err = parse_params(&raw).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("KEY=VALUE"),
"error should mention format: {msg}"
);
}
#[test]
fn test_param_resolve_params_precedence() {
use crate::manifest::Param;
use indexmap::IndexMap;
let mut params = IndexMap::new();
params.insert(
"a".to_string(),
Param {
default: Some("default_a".to_string()),
},
);
params.insert(
"b".to_string(),
Param {
default: Some("default_b".to_string()),
},
);
params.insert(
"c".to_string(),
Param {
default: Some("default_c".to_string()),
},
);
let mut dotenv_vars = std::collections::HashMap::new();
dotenv_vars.insert("a".to_string(), "dotenv_a".to_string());
dotenv_vars.insert("b".to_string(), "dotenv_b".to_string());
let cli_params = vec![("a".to_string(), "cli_a".to_string())];
let resolved = resolve_params(¶ms, &dotenv_vars, &cli_params).unwrap();
assert_eq!(resolved["ARC_PARAM_A"], "cli_a");
assert_eq!(resolved["ARC_PARAM_B"], "dotenv_b");
assert_eq!(resolved["ARC_PARAM_C"], "default_c");
}
#[test]
fn test_param_missing_required_param() {
use crate::manifest::Param;
use indexmap::IndexMap;
let mut params = IndexMap::new();
params.insert("required_param".to_string(), Param { default: None });
let dotenv_vars = std::collections::HashMap::new();
let cli_params: Vec<(String, String)> = vec![];
let err = resolve_params(¶ms, &dotenv_vars, &cli_params).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("required_param"),
"error should name the param: {msg}"
);
assert!(msg.contains("missing"), "error should say missing: {msg}");
}
#[test]
fn test_param_arc_param_prefix_uppercasing() {
use crate::manifest::Param;
use indexmap::IndexMap;
let mut params = IndexMap::new();
params.insert("start_date".to_string(), Param { default: None });
let dotenv_vars = std::collections::HashMap::new();
let cli_params = vec![("start_date".to_string(), "2026-01-01".to_string())];
let resolved = resolve_params(¶ms, &dotenv_vars, &cli_params).unwrap();
assert_eq!(resolved.get("ARC_PARAM_START_DATE").unwrap(), "2026-01-01");
}
#[test]
fn test_param_mock_engine_records_env() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
params:
region:
default: "us-west-2"
steps:
- name: s1
sql: models/s1.sql
"#;
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_call = calls.iter().find(|c| matches!(c, MockCall::Sql { .. }));
match sql_call {
Some(MockCall::Sql { env, .. }) => {
assert_eq!(env.get("ARC_PARAM_REGION").unwrap(), "us-west-2");
}
_ => panic!("expected SQL call with env"),
}
}
#[test]
fn test_param_dotenv_file_loading() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
params:
db_host: {}
dotenv:
- .env
steps:
- name: greet
command: echo hello
"#;
setup_project(dir.path(), yaml, &[]);
fs::write(dir.path().join(".env"), "db_host=localhost\n").unwrap();
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_call = calls.iter().find(|c| matches!(c, MockCall::Command { .. }));
match cmd_call {
Some(MockCall::Command { env, .. }) => {
assert_eq!(env.get("ARC_PARAM_DB_HOST").unwrap(), "localhost");
}
_ => panic!("expected Command call with env"),
}
}
#[test]
fn test_param_missing_dotenv_silently_skipped() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
params:
x:
default: "fallback"
dotenv:
- .env.local
steps:
- name: greet
command: echo hello
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_call = calls.iter().find(|c| matches!(c, MockCall::Command { .. }));
match cmd_call {
Some(MockCall::Command { env, .. }) => {
assert_eq!(env.get("ARC_PARAM_X").unwrap(), "fallback");
}
_ => panic!("expected Command call"),
}
}
#[test]
fn test_param_output_capture_available_downstream() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: get_date
command: "echo 2026-04-20"
output: current_date
- name: use_date
command: "echo using date"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_simulated_stdout("2026-04-20");
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Command { .. }))
.collect();
assert_eq!(cmd_calls.len(), 2, "should have 2 command calls");
match &cmd_calls[1] {
MockCall::Command { env, .. } => {
assert_eq!(
env.get("ARC_PARAM_CURRENT_DATE").unwrap(),
"2026-04-20",
"downstream step should see captured output"
);
}
_ => unreachable!(),
}
}
#[test]
fn test_param_empty_stdout_sets_empty_string() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: get_empty
command: "true"
output: result
- name: use_result
command: "echo done"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_simulated_stdout(""); let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Command { .. }))
.collect();
assert_eq!(cmd_calls.len(), 2);
match &cmd_calls[1] {
MockCall::Command { env, .. } => {
assert!(
env.contains_key("ARC_PARAM_RESULT"),
"env var should exist even for empty stdout"
);
assert_eq!(env["ARC_PARAM_RESULT"], "", "empty stdout → empty string");
}
_ => unreachable!(),
}
}
#[test]
fn test_param_sql_step_output_rejected() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: bad
sql: models/bad.sql
output: result
"#;
setup_project(dir.path(), yaml, &[("models/bad.sql", "SELECT 1;")]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("SQL steps cannot declare an output"),
"should reject SQL + output: {msg}"
);
}
#[test]
fn a_database_the_run_cannot_resolve_refuses_it_before_any_step() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: a/b\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
assert!(
matches!(&err, Error::DbNameNotAFileName { name } if name == "a/b"),
"{err:?}"
);
assert!(
!engine
.calls
.borrow()
.iter()
.any(|c| matches!(c, MockCall::Sql { .. })),
"no step reached the engine"
);
assert!(state.runs.borrow().is_empty(), "no run was recorded");
}
#[test]
fn test_param_backwards_compat_no_params() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_call = calls.iter().find(|c| matches!(c, MockCall::Sql { .. }));
match sql_call {
Some(MockCall::Sql { env, .. }) => {
let keys: Vec<&String> = env.keys().collect();
assert_eq!(
keys,
vec!["ARC_DB_PATH"],
"backwards-compat: no ARC_PARAM_ variable without params"
);
}
_ => panic!("expected SQL call"),
}
}
#[test]
fn test_param_param_staleness_independence() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
params:
region:
default: "us-west-2"
steps:
- name: transform
sql: models/transform.sql
"#;
setup_project(dir.path(), yaml, &[("models/transform.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let first_calls = engine.calls.borrow().len();
assert_eq!(first_calls, 2, "first run: preflight + 1 SQL");
let engine2 = MockEngine::new();
let cli_params = vec![("region".to_string(), "eu-west-1".to_string())];
run_with_params(dir.path(), &engine2, &state, false, &cli_params).unwrap();
let calls = engine2.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
0,
"changing param value should not make SQL step stale (no engine call)"
);
}
#[test]
fn test_res_retry_policy_deserialises() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
defaults:
retry:
max_attempts: 3
backoff_sec: 2.0
steps:
- name: greet
command: echo hello
"#;
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
let defaults = manifest.defaults.unwrap();
let retry = defaults.retry.unwrap();
assert_eq!(retry.max_attempts, 3);
assert_eq!(retry.backoff_sec, 2.0);
}
#[test]
fn test_res_no_defaults_is_none() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n";
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
assert!(manifest.defaults.is_none());
}
#[test]
fn test_res_step_retry_and_timeout() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: fetch
command: "curl http://example.com"
retry:
max_attempts: 5
backoff_sec: 1.5
timeout_sec: 30.0
"#;
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
let step = &manifest.steps[0];
let retry = step.retry.as_ref().unwrap();
assert_eq!(retry.max_attempts, 5);
assert_eq!(retry.backoff_sec, 1.5);
assert_eq!(step.timeout_sec, Some(30.0));
}
#[test]
fn test_res_retry_exhaustion() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: flaky
command: "echo flaky"
retry:
max_attempts: 2
backoff_sec: 0.0
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_failure(1, "always fail");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err(), "should fail after exhausting retries");
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Command { .. }))
.collect();
assert_eq!(cmd_calls.len(), 2, "should have made 2 attempts");
}
#[test]
fn test_res_retry_succeeds_on_second_attempt() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: flaky
command: "echo flaky"
retry:
max_attempts: 3
backoff_sec: 0.0
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_fail_on_call(0, 1, "transient"); let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Command { .. }))
.collect();
assert_eq!(cmd_calls.len(), 2, "should have retried once and succeeded");
}
#[test]
fn test_res_backoff_duration() {
use crate::manifest::RetryPolicy;
let policy = RetryPolicy {
max_attempts: 5,
backoff_sec: 2.0,
};
assert_eq!(
backoff_duration(&policy, 1),
std::time::Duration::from_secs(2)
);
assert_eq!(
backoff_duration(&policy, 2),
std::time::Duration::from_secs(4)
);
assert_eq!(
backoff_duration(&policy, 3),
std::time::Duration::from_secs(8)
);
}
#[test]
fn test_res_defaults_resolution() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
defaults:
retry:
max_attempts: 3
backoff_sec: 0.0
steps:
- name: flaky
command: "echo flaky"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_fail_on_call(0, 1, "transient");
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Command { .. }))
.collect();
assert_eq!(
cmd_calls.len(),
2,
"defaults.retry should apply when step has no retry"
);
}
#[test]
fn test_res_step_overrides_defaults() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
defaults:
retry:
max_attempts: 5
backoff_sec: 0.0
steps:
- name: flaky
command: "echo flaky"
retry:
max_attempts: 1
backoff_sec: 0.0
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_failure(1, "always fail");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(
result.is_err(),
"step max_attempts=1 should override defaults"
);
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Command { .. }))
.collect();
assert_eq!(
cmd_calls.len(),
1,
"step override to 1 attempt should mean only 1 call"
);
}
#[test]
fn test_res_mock_timeout_fires() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: slow
command: "sleep 999"
timeout_sec: 5.0
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_timeout_fire();
let state = MockStateBackend::new();
let err = run(dir.path(), &engine, &state, false).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("timed out"),
"should be a timeout error: {msg}"
);
}
#[test]
fn test_res_no_timeout_no_error() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: fast\n command: echo hi\n";
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_timeout_fire(); let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
}
#[test]
fn test_res_error_messages() {
let timeout_err = crate::error::Error::StepTimeout {
step: "fetch".to_string(),
};
assert!(timeout_err.to_string().contains("fetch"));
assert!(timeout_err.to_string().contains("timed out"));
let pipeline_err = crate::error::Error::PipelineTimeout {
step: "transform".to_string(),
elapsed_sec: 30.5,
};
let msg = pipeline_err.to_string();
assert!(msg.contains("transform"));
assert!(msg.contains("30.5"));
}
#[test]
fn test_res_state_final_outcome_and_retries() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: flaky
command: "echo flaky"
retry:
max_attempts: 3
backoff_sec: 0.0
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_fail_on_call(0, 1, "transient"); let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let states = state.states.borrow();
let step_state = states.get("flaky").unwrap();
assert_eq!(
step_state.status,
StepStatus::Success,
"final outcome should be Success"
);
assert_eq!(state.total_retries.get(), 1, "should record 1 retry");
}
#[test]
fn test_res_backwards_compat() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
assert_eq!(calls.len(), 2);
}
#[test]
fn test_res_reject_max_attempts_zero() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: bad
command: echo hi
retry:
max_attempts: 0
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("max_attempts"),
"should reject max_attempts=0: {msg}"
);
}
#[test]
fn test_res_reject_negative_backoff() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: bad
command: echo hi
retry:
max_attempts: 3
backoff_sec: -1.0
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("backoff_sec"),
"should reject negative backoff: {msg}"
);
}
#[test]
fn test_res_pipeline_timeout() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
timeout_sec: 0.001
steps:
- name: step1
command: "echo one"
- name: step2
command: "echo two"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
if let Err(e) = result {
let msg = e.to_string();
assert!(
msg.contains("pipeline timeout") || msg.contains("timed out"),
"error should be pipeline timeout: {msg}"
);
}
}
#[test]
fn test_res_pipeline_timeout_deterministic() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
timeout_sec: 0.001
steps:
- name: slow
command: "echo slow"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_timeout_fire();
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
}
#[test]
fn test_res_retry_call_count() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: flaky
command: "echo flaky"
retry:
max_attempts: 3
backoff_sec: 0.0
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_failure(1, "always fail");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err(), "should fail after exhausting retries");
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Command { .. }))
.collect();
assert_eq!(
cmd_calls.len(),
3,
"should have made 3 attempts (with retry separators between)"
);
assert_eq!(state.total_retries.get(), 2, "should record 2 retries");
}
#[test]
fn test_res_timeout_is_retryable() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: slow
command: "echo slow"
timeout_sec: 5.0
retry:
max_attempts: 3
backoff_sec: 0.0
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_timeout_fire(); let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err(), "should fail after timeout exhaustion");
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Command { .. }))
.collect();
assert_eq!(
cmd_calls.len(),
3,
"StepTimeout should be retried (3 attempts)"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("timed out"),
"final error should be StepTimeout: {msg}"
);
}
#[test]
fn test_hook_hooks_deserialise() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
on_success:
name: notify
command: "echo ok"
on_failure:
name: alert
command: "echo fail"
on_exit:
name: teardown
command: "echo exit"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
assert!(manifest.hooks.on_init.is_some());
assert!(manifest.hooks.on_success.is_some());
assert!(manifest.hooks.on_failure.is_some());
assert!(manifest.hooks.on_exit.is_some());
assert_eq!(manifest.hooks.on_init.unwrap().name, "setup");
}
#[test]
fn test_hook_no_hooks_is_none() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n";
setup_project(dir.path(), yaml, &[]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
assert!(manifest.hooks.on_init.is_none());
assert!(manifest.hooks.on_success.is_none());
assert!(manifest.hooks.on_failure.is_none());
assert!(manifest.hooks.on_exit.is_none());
}
#[test]
fn test_hook_init_runs_before_steps() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert_eq!(cmd_calls, vec!["echo init", "echo loading"]);
}
#[test]
fn test_hook_init_failure_aborts_but_exit_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
on_exit:
name: teardown
command: "echo exit"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_fail_on_call(0, 1, "init boom");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err(), "should fail when on_init fails");
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert_eq!(cmd_calls, vec!["echo init", "echo exit"]);
}
#[test]
fn test_hook_success_hook_runs() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_success:
name: notify
command: "echo ok"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert_eq!(cmd_calls, vec!["echo loading", "echo ok"]);
}
#[test]
fn test_hook_success_not_called_on_failure() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_success:
name: notify
command: "echo ok"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_failure(1, "boom");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert_eq!(cmd_calls, vec!["echo loading"]);
}
#[test]
fn test_hook_failure_hook_with_env() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_failure:
name: alert
command: "echo fail"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_failure(1, "step error");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let calls = engine.calls.borrow();
let failure_call = calls.iter().find(|c| match c {
MockCall::Command { command, .. } => command == "echo fail",
_ => false,
});
assert!(
failure_call.is_some(),
"on_failure hook should have been called"
);
if let MockCall::Command { env, .. } = failure_call.unwrap() {
assert_eq!(env.get("ARC_FAILED_STEP"), Some(&"load".to_string()));
assert_eq!(env.get("ARC_EXIT_CODE"), Some(&"1".to_string()));
}
}
#[test]
fn test_hook_failure_not_called_on_success() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_failure:
name: alert
command: "echo fail"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert_eq!(cmd_calls, vec!["echo loading"]);
}
#[test]
fn test_hook_exit_on_success() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_exit:
name: teardown
command: "echo exit"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let exit_call = calls.iter().find(|c| match c {
MockCall::Command { command, .. } => command == "echo exit",
_ => false,
});
assert!(exit_call.is_some(), "on_exit should run on success");
if let MockCall::Command { env, .. } = exit_call.unwrap() {
assert_eq!(env.get("ARC_PIPELINE_STATUS"), Some(&"success".to_string()));
assert!(
env.get("ARC_FAILED_STEP").is_none(),
"no ARC_FAILED_STEP on success"
);
}
}
#[test]
fn test_hook_exit_on_failure() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_exit:
name: teardown
command: "echo exit"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_failure(1, "step error");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let calls = engine.calls.borrow();
let exit_call = calls.iter().find(|c| match c {
MockCall::Command { command, .. } => command == "echo exit",
_ => false,
});
assert!(exit_call.is_some(), "on_exit should run on failure");
if let MockCall::Command { env, .. } = exit_call.unwrap() {
assert_eq!(env.get("ARC_PIPELINE_STATUS"), Some(&"failed".to_string()));
assert_eq!(env.get("ARC_FAILED_STEP"), Some(&"load".to_string()));
}
}
#[test]
fn test_hook_exit_on_init_failure() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
on_exit:
name: teardown
command: "echo exit"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_fail_on_call(0, 1, "init error");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let calls = engine.calls.borrow();
let exit_call = calls.iter().find(|c| match c {
MockCall::Command { command, .. } => command == "echo exit",
_ => false,
});
assert!(
exit_call.is_some(),
"on_exit should run even when on_init fails"
);
if let MockCall::Command { env, .. } = exit_call.unwrap() {
assert_eq!(
env.get("ARC_PIPELINE_STATUS"),
Some(&"init_failed".to_string())
);
assert!(
env.get("ARC_FAILED_STEP").is_none(),
"no ARC_FAILED_STEP on init failure"
);
}
}
#[test]
fn test_hook_success_hook_failure_nonfatal() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_success:
name: notify
command: "echo notify"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_fail_on_call(1, 1, "hook error");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(
result.is_ok(),
"pipeline should succeed despite on_success hook failure"
);
}
#[test]
fn test_hook_failure_hook_failure_returns_original() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_failure:
name: alert
command: "echo alert"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_failure(1, "original error");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("load"),
"error should name the failed step: {msg}"
);
}
#[test]
fn test_hook_reject_preconditions() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
preconditions:
- command: "true"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("preconditions"),
"should reject preconditions on hooks: {msg}"
);
}
#[test]
fn test_hook_reject_retry() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
retry:
max_attempts: 3
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("retry"), "should reject retry on hooks: {msg}");
}
#[test]
fn test_hook_reject_timeout() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
timeout_sec: 30.0
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("timeout_sec"),
"should reject timeout_sec on hooks: {msg}"
);
}
#[test]
fn test_hook_reject_produces() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
produces:
- some_asset
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("produces"),
"should reject produces on hooks: {msg}"
);
}
#[test]
fn test_hook_reject_depends_on() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
depends_on:
- some_asset
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("depends_on"),
"should reject depends_on on hooks: {msg}"
);
}
#[test]
fn test_hook_reject_output() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_success:
name: notify
command: "echo ok"
output: captured_value
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("output"),
"should reject output on hooks: {msg}"
);
}
#[test]
fn test_hook_reject_name_collision() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: load
command: "echo init"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let err = crate::manifest::Manifest::load(dir.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("collides"),
"should reject name collision: {msg}"
);
}
#[test]
fn test_hook_backwards_compat() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: greet\n command: echo hello\n";
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert_eq!(
cmd_calls,
vec!["echo hello"],
"should work identically without hooks"
);
}
#[test]
fn test_hook_sql_hook() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
sql: hooks/setup.sql
steps:
- name: load
command: "echo loading"
"#;
setup_project(
dir.path(),
yaml,
&[("hooks/setup.sql", "CREATE TABLE staging (id INT);")],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let sql_calls: Vec<_> = calls
.iter()
.filter(|c| matches!(c, MockCall::Sql { .. }))
.collect();
assert_eq!(
sql_calls.len(),
1,
"SQL hook should produce one execute_sql call"
);
}
#[test]
fn test_hook_command_hook() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_exit:
name: teardown
command: "echo cleanup"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert!(
cmd_calls.contains(&"echo cleanup"),
"on_exit command hook should run"
);
}
#[test]
fn test_hook_full_lifecycle_success() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
on_success:
name: notify
command: "echo ok"
on_failure:
name: alert
command: "echo fail"
on_exit:
name: teardown
command: "echo exit"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert_eq!(
cmd_calls,
vec!["echo init", "echo loading", "echo ok", "echo exit"]
);
}
#[test]
fn test_hook_full_lifecycle_failure() {
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
hooks:
on_init:
name: setup
command: "echo init"
on_success:
name: notify
command: "echo ok"
on_failure:
name: alert
command: "echo fail"
on_exit:
name: teardown
command: "echo exit"
steps:
- name: load
command: "echo loading"
"#;
setup_project(dir.path(), yaml, &[]);
let engine = MockEngine::new();
engine.set_fail_on_call(1, 1, "step error");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err());
let calls = engine.calls.borrow();
let cmd_calls: Vec<_> = calls
.iter()
.filter_map(|c| match c {
MockCall::Command { command, .. } => Some(command.as_str()),
_ => None,
})
.collect();
assert_eq!(
cmd_calls,
vec!["echo init", "echo loading", "echo fail", "echo exit"]
);
}
#[test]
fn test_contract_persisted_with_row_count_and_stream() {
use crate::contract::{Contract, StepKind};
let dir = tempfile::tempdir().unwrap();
let yaml = "name: widgets\ndb: pipeline.duckdb\nsteps:\n - name: load\n sql: models/load.sql\n - name: tally\n sql: models/tally.sql\n";
setup_project(
dir.path(),
yaml,
&[
(
"models/load.sql",
"CREATE TABLE widgets (id INTEGER, name TEXT);\n",
),
(
"models/tally.sql",
"CREATE TABLE widget_tally AS SELECT count(*) AS n FROM widgets;\n",
),
],
);
{
let conn = duckdb::Connection::open(dir.path().join("pipeline.duckdb")).unwrap();
conn.execute_batch(
"CREATE TABLE widgets (id INTEGER, name TEXT); \
INSERT INTO widgets VALUES (1,'a'),(2,'b'),(3,'c');",
)
.unwrap();
}
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let json_path = dir.path().join("build/.arcform/runs/run-1.json");
assert!(
json_path.exists(),
"contract JSON should exist at {json_path:?}"
);
let raw = std::fs::read_to_string(&json_path).unwrap();
let contract: Contract = serde_json::from_str(&raw).expect("contract JSON deserializes");
assert_eq!(contract.contract_version, "b4/1");
assert_eq!(contract.run.outcome, "success");
assert_eq!(contract.run.run_id, "run-1");
assert_eq!(contract.run.attempt_id, "run-1");
assert_eq!(contract.run.engine.arc, env!("CARGO_PKG_VERSION"));
assert!(contract.run.protocol.manifest_sha256.is_some());
let widgets = contract
.assets
.iter()
.find(|a| a.name == "widgets")
.expect("widgets asset present");
assert_eq!(widgets.id, "table:widgets");
assert_eq!(widgets.kind, "table");
assert_eq!(
widgets.row_count,
Some(3),
"row_count measured from the run db"
);
assert_eq!(widgets.produced_by.as_deref(), Some("load"));
assert!(widgets.consumed_by.contains(&"tally".to_string()));
let load = contract.steps.iter().find(|s| s.name == "load").unwrap();
assert_eq!(load.kind, StepKind::Sql);
let sql = load.sql.as_ref().expect("sql detail present");
assert!(!sql.sql_text.is_empty(), "sql_text captured");
assert!(!sql.sql_hash.is_empty(), "sql_hash captured");
let widget_stmt = sql
.statements
.iter()
.find(|st| st.produces.contains(&"widgets".to_string()))
.expect("per-statement lineage records the produced table");
assert!(
widgets.bytes.is_some(),
"table bytes measured via estimated_size"
);
let hash = widgets
.content_hash
.as_ref()
.expect("table content_hash computed");
assert_eq!(hash.len(), 64, "content_hash is sha256 hex: {hash}");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"content_hash is hex: {hash}"
);
let [lo, hi] = widget_stmt
.byte_range
.expect("statement carries a byte range");
assert!(sql.sql_text[lo..hi].to_lowercase().contains("widgets"));
assert!(
load.attempts >= 1,
"executed step records its attempt count"
);
assert!(
load.duration_sec.is_some(),
"executed step records its duration"
);
assert!(
load.status.skip_reason.is_none(),
"an executed step has no skip reason"
);
let jsonl_path = dir.path().join("build/.arcform/runs/run-1.jsonl");
assert!(jsonl_path.exists(), "status stream should exist");
let lines: Vec<serde_json::Value> = std::fs::read_to_string(&jsonl_path)
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert!(
lines
.iter()
.any(|l| l["step"] == "load" && l["state"] == "success"),
"stream has load success line"
);
assert!(
lines
.iter()
.any(|l| l["step"] == "tally" && l["state"] == "success"),
"stream has tally success line"
);
assert!(
lines
.iter()
.any(|l| l["event"] == "run_complete" && l["outcome"] == "success"),
"stream has terminal run_complete line"
);
}
#[test]
fn a_step_reruns_when_its_declared_tool_changes_and_says_so_in_the_contract() {
use crate::contract::Contract;
let dir = tempfile::tempdir().unwrap();
let yaml = r#"
name: test
steps:
- name: describe
command: "echo describing"
preconditions:
- tool:
path: bin/faketool
version: "$ARC_TOOL --version"
- name: unrelated
command: "echo unrelated"
preconditions:
- command: "true"
"#;
setup_project(dir.path(), yaml, &[]);
let tool = dir.path().join("bin/faketool");
let install = |version: &str| {
fs::create_dir_all(tool.parent().unwrap()).unwrap();
fs::write(&tool, format!("#!/bin/sh\necho 'faketool {version}'\n")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tool, fs::Permissions::from_mode(0o755)).unwrap();
}
};
let outcome = |run_id: &str, step: &str| {
let raw = fs::read_to_string(
dir.path()
.join(format!("build/.arcform/runs/{run_id}.json")),
)
.unwrap();
let contract: Contract = serde_json::from_str(&raw).unwrap();
let s = contract
.steps
.iter()
.find(|s| s.name == step)
.unwrap_or_else(|| panic!("{step} present in {run_id}"));
(
s.status.state.clone(),
s.status.skip_reason.map(|r| r.as_str().to_string()),
)
};
install("1.0.0");
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
outcome("run-1", "describe"),
("success".to_string(), None),
"a step that has never run against this tool runs"
);
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
outcome("run-2", "describe"),
("skipped".to_string(), Some("precondition_tool".to_string())),
"an unchanged tool skips the step, and the contract says which mechanism \
decided it"
);
assert_eq!(
outcome("run-2", "unrelated"),
(
"skipped".to_string(),
Some("precondition_fresh".to_string())
),
);
install("1.0.1");
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
outcome("run-3", "describe"),
("success".to_string(), None),
"a tool reporting a different version re-runs the step"
);
assert_eq!(
outcome("run-3", "unrelated"),
(
"skipped".to_string(),
Some("precondition_fresh".to_string())
),
"and the step that does not declare the tool is untouched by its move"
);
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
outcome("run-4", "describe"),
("skipped".to_string(), Some("precondition_tool".to_string())),
);
}
#[test]
fn test_contract_records_failed_step_and_partial_outcome() {
use crate::contract::Contract;
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n - name: s2\n sql: models/s2.sql\n";
setup_project(
dir.path(),
yaml,
&[
("models/s1.sql", "CREATE TABLE t (id INT);"),
("models/s2.sql", "CREATE TABLE u AS SELECT * FROM t;"),
],
);
let engine = MockEngine::new();
engine.set_fail_on_call(1, 1, "boom");
let state = MockStateBackend::new();
let result = run(dir.path(), &engine, &state, false);
assert!(result.is_err(), "run should fail on s2");
let json_path = dir.path().join("build/.arcform/runs/run-1.json");
let raw = std::fs::read_to_string(&json_path).unwrap();
let contract: Contract = serde_json::from_str(&raw).unwrap();
assert_eq!(contract.run.outcome, "partial", "s1 succeeded, s2 failed");
assert_eq!(
state.runs.borrow()[0]
.1
.as_ref()
.map(|(_, outcome)| outcome.as_str()),
Some("failed"),
"the run table's outcome for a failed run"
);
let s1 = contract.steps.iter().find(|s| s.name == "s1").unwrap();
let s2 = contract.steps.iter().find(|s| s.name == "s2").unwrap();
assert_eq!(s1.status.state, "success");
assert_eq!(s2.status.state, "failed");
let jsonl =
std::fs::read_to_string(dir.path().join("build/.arcform/runs/run-1.jsonl")).unwrap();
let lines: Vec<serde_json::Value> = jsonl
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert!(
lines
.iter()
.any(|l| l["step"] == "s2" && l["state"] == "failed")
);
assert!(
lines
.iter()
.any(|l| l["event"] == "run_complete" && l["outcome"] == "partial")
);
}
#[cfg(feature = "http-fetch")]
#[test]
fn a_run_hands_the_shared_fetch_cache_to_its_operators() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
const CACHE_ENV: &str = "ARCFORM_FETCH_CACHE";
const URL: &str = "http://no-origin-is-reachable.invalid/artifact.bin";
struct CacheRoot {
_lock: std::sync::MutexGuard<'static, ()>,
}
impl CacheRoot {
fn set(root: &std::path::Path) -> Self {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var(CACHE_ENV, root) };
Self { _lock }
}
}
impl Drop for CacheRoot {
fn drop(&mut self) {
unsafe { std::env::remove_var(CACHE_ENV) };
}
}
let bytes = b"the artifact the shared store already holds";
let digest = crate::state::content_hash(bytes);
let store = tempfile::tempdir().unwrap();
let root = store.path().join("cache");
let seed = tempfile::tempdir().unwrap();
let source = seed.path().join("artifact.bin");
fs::write(&source, bytes).unwrap();
crate::fetch_cache::FetchCache::at(root.clone())
.store(
&crate::ingress_meta::FetchMeta {
url: URL.to_string(),
sha256: digest.clone(),
..Default::default()
},
&source,
)
.unwrap();
let dir = tempfile::tempdir().unwrap();
setup_project(
dir.path(),
&format!(
"name: test\nsteps:\n - name: fetch\n op: http_fetch\n with:\n url: {URL}\n out: build/artifact.bin\n sha256: {digest}\n"
),
&[],
);
let _cache_root = CacheRoot::set(&root);
run(
dir.path(),
&MockEngine::new(),
&MockStateBackend::new(),
false,
)
.expect("a pinned artifact the store holds needs no origin");
assert_eq!(
fs::read(dir.path().join("build/artifact.bin")).unwrap(),
bytes,
"the bytes came off the shared store"
);
}
#[test]
fn test_sql_introspected_glob_does_not_force_perpetual_staleness() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: load\n sql: models/load.sql\n";
setup_project(
dir.path(),
yaml,
&[(
"models/load.sql",
"CREATE TABLE ncen_registrant AS SELECT * FROM read_csv('build/ncen/*/REGISTRANT.tsv');",
)],
);
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(engine.calls.borrow().len(), 2, "run 1: preflight + 1 sql");
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"run 2: unchanged SQL (including its glob) must skip, not re-run forever \
because a glob was mistaken for a hashable file"
);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"run 3: same again — must stay skipped, not resume re-running"
);
}
#[test]
fn test_missing_declared_produces_names_the_gap_and_closes_when_fixed() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: s1\n sql: models/s1.sql\n produces: [build/REGISTRANT.tsv]\n";
setup_project(dir.path(), yaml, &[("models/s1.sql", "SELECT 1;")]);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
let asset_graph = AssetGraph::build(&manifest, dir.path());
let step = manifest.steps.iter().find(|s| s.name == "s1").unwrap();
let missing = missing_declared_produces(step, dir.path(), &asset_graph);
assert_eq!(
missing,
vec!["build/REGISTRANT.tsv".to_string()],
"a declared produces: file that was never written must be named"
);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/REGISTRANT.tsv"), "now it exists\n").unwrap();
let missing = missing_declared_produces(step, dir.path(), &asset_graph);
assert!(
missing.is_empty(),
"declared file now exists — must not still be reported missing: {missing:?}"
);
}
#[test]
fn test_reader_case_mismatch_settles_to_skip_not_perpetual_staleness() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: produce\n sql: models/produce.sql\n produces: [build/REGISTRANT.tsv]\n - name: consume\n sql: models/consume.sql\n depends_on: [build/registrant.tsv]\n";
setup_project(
dir.path(),
yaml,
&[
("models/produce.sql", "SELECT 1;"),
("models/consume.sql", "SELECT 2;"),
],
);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/REGISTRANT.tsv"), "real content\n").unwrap();
if dir.path().join("build/registrant.tsv").exists() {
eprintln!(
"skipping test_reader_case_mismatch_settles_to_skip_not_perpetual_staleness: \
{} does not distinguish case, so build/registrant.tsv already exists via \
folding",
dir.path().display()
);
return;
}
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(engine.calls.borrow().len(), 3, "run 1: preflight + 2 sql");
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"run 2: both steps unchanged, must settle to preflight only"
);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"run 3: same again — must stay settled, not resume re-running"
);
}
#[test]
fn test_directory_emptied_while_kept_forces_rerun_not_perpetual_skip() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: export\n sql: models/export.sql\n";
setup_project(
dir.path(),
yaml,
&[(
"models/export.sql",
"COPY orders TO 'build/orders' (FORMAT parquet, PARTITION_BY (year));",
)],
);
fs::create_dir_all(dir.path().join("build/orders")).unwrap();
fs::write(
dir.path().join("build/orders/year=2024.parquet"),
b"partition A",
)
.unwrap();
fs::write(
dir.path().join("build/orders/year=2025.parquet"),
b"partition B",
)
.unwrap();
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"run 1: preflight + 1 sql (no prior state, always stale)"
);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"run 2: unchanged directory, must settle to skip"
);
fs::remove_file(dir.path().join("build/orders/year=2024.parquet")).unwrap();
fs::remove_file(dir.path().join("build/orders/year=2025.parquet")).unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"run 3: directory emptied while kept present must force a re-run, not \
skip forever — round 7's regression, reproduced and closed"
);
fs::write(
dir.path().join("build/orders/year=2026.parquet"),
b"partition C",
)
.unwrap();
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
2,
"run 4: restored (different) content must also force a re-run"
);
drop(engine);
let engine = MockEngine::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
engine.calls.borrow().len(),
1,
"run 5: unchanged again, must settle back to skip"
);
}
#[test]
fn test_parquet_export_bare_dest_forces_rerun_on_truncate_delete_restore() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\ndb: test.duckdb\nsteps:\n - name: export\n op: parquet_export\n with:\n input: customers\n dest: registrant.avro\n";
setup_project(dir.path(), yaml, &[]);
{
let db_path = dir.path().join("test.duckdb");
let conn = duckdb::Connection::open(&db_path).unwrap();
conn.execute_batch("CREATE TABLE customers AS SELECT 1 AS id, 'a' AS name;")
.unwrap();
}
let last_steps_executed = |state: &MockStateBackend| -> usize {
state
.runs
.borrow()
.last()
.and_then(|(_, outcome)| outcome.clone())
.map(|(steps_executed, _)| steps_executed)
.expect("finish_run must have recorded this run")
};
let engine = MockEngine::new();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
last_steps_executed(&state),
1,
"run 1: no prior state, always stale"
);
let dest = dir.path().join("registrant.avro");
assert!(
dest.exists(),
"parquet_export must really write the bare-named, non-allowlisted-extension file"
);
let original_bytes = fs::read(&dest).unwrap();
assert!(!original_bytes.is_empty());
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
last_steps_executed(&state),
0,
"run 2: unchanged, must settle to skip"
);
fs::write(&dest, b"").unwrap();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
last_steps_executed(&state),
1,
"run 3: truncated bare-named file must force a real re-run"
);
let rewritten_bytes = fs::read(&dest).unwrap();
assert_eq!(
rewritten_bytes, original_bytes,
"the re-run really re-executed parquet_export, restoring real bytes, \
not merely reporting success over the truncated file"
);
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
last_steps_executed(&state),
0,
"run 4: restored file unchanged, must settle to skip"
);
fs::remove_file(&dest).unwrap();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
last_steps_executed(&state),
1,
"run 5: deleted bare-named file must force a real re-run"
);
assert!(
dest.exists(),
"deleted file must be really rewritten, not just reported fixed"
);
}
fn engine_calls_for_one_run(dir: &Path, state: &MockStateBackend) -> usize {
let engine = MockEngine::new();
run(dir, &engine, state, false).unwrap();
let n = engine.calls.borrow().len();
drop(engine);
n
}
fn project_with_seeded_directory(sql: &str, files: &[(&str, &[u8])]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: export\n sql: models/export.sql\n";
setup_project(dir.path(), yaml, &[("models/export.sql", sql)]);
for (rel, bytes) in files {
let full = dir.path().join(rel);
fs::create_dir_all(full.parent().unwrap()).unwrap();
fs::write(&full, bytes).unwrap();
}
dir
}
#[test]
fn test_every_directory_writing_copy_option_settles_and_then_notices_a_change() {
for option in [
"PARTITION_BY (year)",
"PER_THREAD_OUTPUT true",
"FILE_SIZE_BYTES '1MB'",
"ROW_GROUPS_PER_FILE 1",
] {
let sql = format!("COPY orders TO 'build/out' (FORMAT parquet, {option});");
let dir = project_with_seeded_directory(
&sql,
&[("build/out/data_0.parquet", b"first slice")],
);
let state = MockStateBackend::new();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"{option}: run 1 has no prior state and must execute"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"{option}: run 2 must settle to skip — a directory-writing COPY \
classified as a File cannot be read and never settles"
);
fs::write(dir.path().join("build/out/data_1.parquet"), b"second slice").unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"{option}: run 3 must notice a file added to the produced directory"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"{option}: run 4 must settle again"
);
}
}
#[test]
fn test_per_thread_output_off_is_hashed_as_one_file_and_settles() {
for arg in ["false", "0", "'false'", "'no'"] {
let dir = project_with_seeded_directory(
&format!(
"COPY orders TO 'build/one.parquet' (FORMAT parquet, PER_THREAD_OUTPUT {arg});"
),
&[("build/one.parquet", b"one file, not a directory")],
);
let state = MockStateBackend::new();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"{arg}: run 1"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"{arg}: run 2 must settle — this argument casts to false and writes \
one file, and treating it as a directory would make the step re-run \
forever"
);
fs::write(dir.path().join("build/one.parquet"), b"different bytes").unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"{arg}: run 3 — the file's bytes changed and it is really being hashed"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"{arg}: run 4 must settle again"
);
}
}
#[test]
fn test_bare_produces_ordering_token_reruns_rather_than_settling() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: load\n sql: models/load.sql\n produces: [raw_tables]\n - name: consume\n sql: models/consume.sql\n depends_on: [raw_tables]\n";
setup_project(
dir.path(),
yaml,
&[
(
"models/load.sql",
"CREATE OR REPLACE TABLE naics_raw AS SELECT 1;",
),
(
"models/consume.sql",
"CREATE OR REPLACE TABLE naics_norm AS SELECT 2;",
),
],
);
let state = MockStateBackend::new();
for run in 1..=4 {
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
3,
"run {run}: `raw_tables` is a declared path with nothing at it, so the \
producer must stay stale and drag its consumer with it — preflight \
plus both sql steps, every run"
);
}
let settling = tempfile::tempdir().unwrap();
let settling_yaml = "name: test\nsteps:\n - name: load\n sql: models/load.sql\n produces: [build/loaded.txt]\n";
setup_project(
settling.path(),
settling_yaml,
&[(
"models/load.sql",
"CREATE OR REPLACE TABLE naics_raw AS SELECT 1;",
)],
);
fs::create_dir_all(settling.path().join("build")).unwrap();
fs::write(settling.path().join("build/loaded.txt"), b"written").unwrap();
let settling_state = MockStateBackend::new();
assert_eq!(
engine_calls_for_one_run(settling.path(), &settling_state),
2,
"control run 1"
);
assert_eq!(
engine_calls_for_one_run(settling.path(), &settling_state),
1,
"control run 2: a declared produces: name that IS on disk settles"
);
}
#[test]
fn test_assets_override_naming_a_real_file_is_hashed_and_notices_deletion() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: export\n sql: models/export.sql\nassets:\n build/side.csv:\n produced_by: export\n";
setup_project(
dir.path(),
yaml,
&[(
"models/export.sql",
"CREATE OR REPLACE TABLE orders AS SELECT 1;",
)],
);
let side = dir.path().join("build/side.csv");
fs::create_dir_all(side.parent().unwrap()).unwrap();
fs::write(&side, b"id\n1\n").unwrap();
let state = MockStateBackend::new();
assert_eq!(engine_calls_for_one_run(dir.path(), &state), 2, "run 1");
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 2: nothing changed, so the step settles"
);
fs::remove_file(&side).unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 3: the overridden asset was DELETED — a run that skips here reports \
success over an artifact that is gone"
);
fs::write(&side, b"id\n1\n").unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 4: restored after an unreadable run, so still stale"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 5: settles again on the restored bytes"
);
fs::write(&side, b"id\n9\n").unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 6: the overridden asset's BYTES changed under an unchanged path"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 7: settles on the new contents"
);
}
#[test]
fn test_assets_override_with_a_separator_free_name_is_still_hashed() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: export\n sql: models/export.sql\nassets:\n side.db:\n produced_by: export\n";
setup_project(
dir.path(),
yaml,
&[(
"models/export.sql",
"ATTACH IF NOT EXISTS 'side.db' AS side;\n\
CREATE OR REPLACE TABLE side.x AS SELECT 1;",
)],
);
let side = dir.path().join("side.db");
fs::write(&side, b"pretend duckdb file").unwrap();
let state = MockStateBackend::new();
assert_eq!(engine_calls_for_one_run(dir.path(), &state), 2, "run 1");
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 2: nothing changed, so the step settles"
);
fs::remove_file(&side).unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 3: the declared artifact is GONE and the step must not certify it"
);
fs::write(&side, b"pretend duckdb file").unwrap();
assert_eq!(engine_calls_for_one_run(dir.path(), &state), 2, "run 4");
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 5: settles again on the restored bytes"
);
fs::write(&side, b"pretend duckdb FILE").unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 6: same path, same length, different bytes"
);
}
#[test]
fn test_directory_file_content_change_with_no_path_change_forces_rerun() {
let dir = project_with_seeded_directory(
"COPY orders TO 'build/parts' (FORMAT parquet, PARTITION_BY (year));",
&[
("build/parts/year=2024.parquet", b"AAAA"),
("build/parts/year=2025.parquet", b"BBBB"),
],
);
let state = MockStateBackend::new();
let paths_now = || {
let mut v: Vec<String> = fs::read_dir(dir.path().join("build/parts"))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
v.sort();
v
};
let paths_at_start = paths_now();
assert_eq!(engine_calls_for_one_run(dir.path(), &state), 2, "run 1");
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 2: unchanged directory must settle"
);
fs::write(dir.path().join("build/parts/year=2024.parquet"), b"ZZZZ").unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 3: a partition file's BYTES changed under an unchanged set of paths \
— a hash over paths alone cannot see this"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 4: settles again on the new content"
);
fs::write(dir.path().join("build/parts/year=2024.parquet"), b"").unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 5: truncating a partition file must force a re-run"
);
assert_eq!(
paths_now(),
paths_at_start,
"this test must never have added or removed a path — otherwise it is \
pinning the same thing the existing directory tests already pin"
);
}
#[test]
fn test_nested_directory_content_change_forces_rerun() {
let dir = project_with_seeded_directory(
"COPY orders TO 'build/parts' (FORMAT parquet, PARTITION_BY (year, month));",
&[
("build/parts/year=2024/month=01/data_0.parquet", b"jan"),
("build/parts/year=2024/month=02/data_0.parquet", b"feb"),
],
);
let state = MockStateBackend::new();
assert_eq!(engine_calls_for_one_run(dir.path(), &state), 2, "run 1");
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 2: unchanged nested tree must settle"
);
fs::write(
dir.path()
.join("build/parts/year=2024/month=02/data_0.parquet"),
b"FEB",
)
.unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 3: bytes two levels down changed — a non-recursive walk sees an \
empty manifest here and skips forever"
);
fs::remove_file(
dir.path()
.join("build/parts/year=2024/month=01/data_0.parquet"),
)
.unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 4: a nested partition deleted must force a re-run too"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 5: settles again"
);
}
#[test]
fn test_a_hand_declaration_cannot_downgrade_sql_introspections_kind() {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n - name: export\n sql: models/export.sql\n produces: [build/parts]\n";
setup_project(
dir.path(),
yaml,
&[(
"models/export.sql",
"COPY orders TO 'build/parts' (FORMAT parquet, PARTITION_BY (year));",
)],
);
fs::create_dir_all(dir.path().join("build/parts")).unwrap();
fs::write(dir.path().join("build/parts/year=2024.parquet"), b"one").unwrap();
let state = MockStateBackend::new();
assert_eq!(engine_calls_for_one_run(dir.path(), &state), 2, "run 1");
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 2: the SQL said Directory and the later hand-declaration must not \
downgrade it to File — a File read of a directory never settles"
);
fs::remove_file(dir.path().join("build/parts/year=2024.parquet")).unwrap();
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 3: emptied directory must force a re-run"
);
}
#[test]
fn test_absent_directory_produce_never_settles_into_skip() {
let dir = project_with_seeded_directory(
"COPY orders TO 'build/parts' (FORMAT parquet, PARTITION_BY (year));",
&[],
);
let state = MockStateBackend::new();
assert!(!dir.path().join("build/parts").exists());
assert_eq!(engine_calls_for_one_run(dir.path(), &state), 2, "run 1");
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 2: a produced directory that is not there must never read as \
unchanged, however stable the placeholder recorded for it"
);
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
2,
"run 3: same again"
);
fs::create_dir_all(dir.path().join("build/parts")).unwrap();
fs::write(dir.path().join("build/parts/year=2024.parquet"), b"real").unwrap();
assert_eq!(engine_calls_for_one_run(dir.path(), &state), 2, "run 4");
assert_eq!(
engine_calls_for_one_run(dir.path(), &state),
1,
"run 5: settles once the directory exists — the staleness above was the \
absence, not a stuck step"
);
}
#[test]
fn a_star_matches_within_a_component_and_a_question_mark_matches_one_character() {
assert!(component_matches("*.csv", "data.csv"));
assert!(component_matches("*.csv", ".csv"));
assert!(component_matches("*", "anything"));
assert!(component_matches("part-*.parquet", "part-0001.parquet"));
assert!(!component_matches("*.csv", "data.tsv"));
assert!(!component_matches("part-*.parquet", "part-0001.csv"));
assert!(component_matches("x?.csv", "x1.csv"));
assert!(!component_matches("x?.csv", "x.csv"));
assert!(!component_matches("x?.csv", "x12.csv"));
}
#[test]
fn a_character_class_accepts_its_members_and_refuses_the_rest() {
assert!(component_matches("[abc].csv", "b.csv"));
assert!(!component_matches("[abc].csv", "d.csv"));
assert!(component_matches("q[0-9].tsv", "q7.tsv"));
assert!(!component_matches("q[0-9].tsv", "qx.tsv"));
assert!(component_matches("[!0-9].csv", "a.csv"));
assert!(!component_matches("[!0-9].csv", "5.csv"));
assert!(component_matches("[axz].csv", "x.csv"));
assert!(!component_matches("[axz].csv", "m.csv"));
assert!(component_matches("[ab].csv", "b.csv"));
assert!(!component_matches("[ab].csv", "c.csv"));
assert!(component_matches("[abc.csv", "[abc.csv"));
assert!(!component_matches("[abc.csv", "a.csv"));
assert!(component_matches("[]x.csv", "[]x.csv"));
assert!(!component_matches("[]x.csv", "x.csv"));
assert!(!component_matches("[abc]", ""));
assert!(!component_matches("[!abc]", ""));
assert!(!component_matches("?", ""));
assert!(component_matches("*", ""));
}
fn pattern_tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("build/src/nested")).unwrap();
fs::write(dir.path().join("build/src/a.csv"), b"one").unwrap();
fs::write(dir.path().join("build/src/b.csv"), b"two").unwrap();
fs::write(dir.path().join("build/src/notes.txt"), b"ignored").unwrap();
fs::write(dir.path().join("build/src/nested/c.csv"), b"deep").unwrap();
dir
}
#[test]
fn a_patterns_hash_follows_the_files_it_matches_and_nothing_else() {
let dir = pattern_tree();
let base = dir.path();
let pattern = "build/src/*.csv";
let start = hash_pattern_matches(base, pattern).expect("a readable tree answers");
fs::write(base.join("build/src/a.csv"), b"one more").unwrap();
let after_edit = hash_pattern_matches(base, pattern).unwrap();
assert_ne!(start, after_edit, "a matched file's bytes changed");
fs::write(base.join("build/src/a.csv"), b"").unwrap();
let after_truncate = hash_pattern_matches(base, pattern).unwrap();
assert_ne!(after_edit, after_truncate, "a matched file was truncated");
fs::write(base.join("build/src/d.csv"), b"new").unwrap();
let after_add = hash_pattern_matches(base, pattern).unwrap();
assert_ne!(after_truncate, after_add, "a file joined the match set");
fs::remove_file(base.join("build/src/d.csv")).unwrap();
assert_eq!(
after_truncate,
hash_pattern_matches(base, pattern).unwrap(),
"removing what was added returns the same set to the same digest"
);
let before_sibling = hash_pattern_matches(base, pattern).unwrap();
fs::write(base.join("build/src/notes.txt"), b"rewritten entirely").unwrap();
assert_eq!(
before_sibling,
hash_pattern_matches(base, pattern).unwrap(),
"notes.txt does not match *.csv, so rewriting it is not a change to the set"
);
fs::write(base.join("build/src/nested/c.csv"), b"rewritten").unwrap();
assert_eq!(
before_sibling,
hash_pattern_matches(base, pattern).unwrap(),
"build/src/*.csv must not reach build/src/nested/c.csv"
);
}
#[test]
fn an_unmatched_pattern_answers_and_an_unlistable_tree_does_not() {
let dir = pattern_tree();
let base = dir.path();
let with_files = hash_pattern_matches(base, "build/src/*.csv").unwrap();
let empty = hash_pattern_matches(base, "build/src/*.jsonl")
.expect("a readable directory that matches nothing still answers");
assert_ne!(
with_files, empty,
"the empty set must not hash like a non-empty one, or deleting the last \
match would read as unchanged"
);
assert_eq!(
hash_pattern_matches(base, "build/missing/*.csv"),
Some(empty.clone()),
"a pattern claims nothing exists, so an absent tree is the empty set"
);
fs::remove_file(base.join("build/src/a.csv")).unwrap();
fs::remove_file(base.join("build/src/b.csv")).unwrap();
assert_eq!(
hash_pattern_matches(base, "build/src/*.csv"),
Some(empty),
"an emptied match set hashes to the empty set"
);
}
#[test]
fn a_pattern_matching_a_directory_hashes_the_files_beside_it() {
let dir = pattern_tree();
let base = dir.path();
let everything = hash_pattern_matches(base, "build/src/*")
.expect("a directory among the matches is not a refusal to answer");
fs::remove_dir_all(base.join("build/src/nested")).unwrap();
assert_eq!(
everything,
hash_pattern_matches(base, "build/src/*").unwrap(),
"removing a directory that matched the pattern must not change the hash — \
it was never part of what the pattern names"
);
}
#[test]
fn a_pattern_with_no_components_refuses_to_answer() {
let dir = pattern_tree();
assert_eq!(hash_pattern_matches(dir.path(), ""), None);
assert_eq!(hash_pattern_matches(dir.path(), "/"), None);
assert_eq!(hash_pattern_matches(dir.path(), "././"), None);
}
fn while_unreadable<T>(path: &Path, f: impl FnOnce() -> T) -> Option<T> {
use std::os::unix::fs::PermissionsExt;
let original = fs::metadata(path).unwrap().permissions();
fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap();
let really_unreadable = if path.is_dir() {
fs::read_dir(path).is_err()
} else {
fs::read(path).is_err()
};
let answer = f();
fs::set_permissions(path, original).unwrap();
really_unreadable.then_some(answer)
}
#[test]
fn an_unreadable_directory_or_file_refuses_to_answer() {
let dir = pattern_tree();
let base = dir.path();
match while_unreadable(&base.join("build/src"), || {
hash_pattern_matches(base, "build/src/*.csv")
}) {
Some(answer) => assert_eq!(
answer, None,
"a directory that exists and cannot be listed is undetermined — the \
caller must force staleness rather than compare against a fabricated \
empty set"
),
None => {
eprintln!("skipping the directory half: this process reads a 0o000 directory")
}
}
match while_unreadable(&base.join("build/src/a.csv"), || {
hash_pattern_matches(base, "build/src/*.csv")
}) {
Some(answer) => assert_eq!(
answer, None,
"a matched file that cannot be read is undetermined, not empty bytes"
),
None => eprintln!("skipping the file half: this process reads a 0o000 file"),
}
}
#[test]
fn a_refusal_in_the_zero_segment_branch_of_a_double_star_propagates() {
let outside = tempfile::tempdir().unwrap();
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
fs::create_dir_all(base.join("other")).unwrap();
fs::write(base.join("other/x.csv"), b"readable").unwrap();
std::os::unix::fs::symlink(outside.path(), base.join("link")).unwrap();
match while_unreadable(outside.path(), || {
hash_pattern_matches(base, "**/link/*.csv")
}) {
Some(answer) => assert_eq!(
answer, None,
"the pattern reached a directory it could not read, so there is no answer — not the empty set assembled from `other/`"
),
None => eprintln!(
"skipping a_refusal_in_the_zero_segment_branch_of_a_double_star_propagates: this process reads a 0o000 directory"
),
}
assert!(
hash_pattern_matches(base, "**/link/*.csv").is_some(),
"the refusal above was the permissions, not the shape of the pattern"
);
}
#[test]
fn a_double_star_spans_zero_or_more_directory_levels() {
let dir = pattern_tree();
let base = dir.path();
let all = hash_pattern_matches(base, "build/src/**/*.csv").unwrap();
let shallow_only = hash_pattern_matches(base, "build/src/*.csv").unwrap();
assert_ne!(
all, shallow_only,
"`**` must reach build/src/nested/c.csv, which `*` does not"
);
fs::write(base.join("build/src/nested/c.csv"), b"changed").unwrap();
let after_deep = hash_pattern_matches(base, "build/src/**/*.csv").unwrap();
assert_ne!(all, after_deep);
assert_eq!(
shallow_only,
hash_pattern_matches(base, "build/src/*.csv").unwrap()
);
fs::write(base.join("build/src/a.csv"), b"also changed").unwrap();
assert_ne!(
after_deep,
hash_pattern_matches(base, "build/src/**/*.csv").unwrap(),
"`**` must also match zero segments, so build/src/a.csv is in the set"
);
let everything = hash_pattern_matches(base, "build/src/**").unwrap();
let empty = hash_pattern_matches(base, "build/src/*.nothing").unwrap();
assert_ne!(
everything, empty,
"a trailing `**` must reach the files, not stop at the directories"
);
fs::write(base.join("build/src/nested/c.csv"), b"changed again").unwrap();
assert_ne!(
everything,
hash_pattern_matches(base, "build/src/**").unwrap(),
"and it must reach the ones below the top level too"
);
}
fn executed_bodies(dir: &Path, state: &MockStateBackend) -> Vec<String> {
let engine = MockEngine::new();
run(dir, &engine, state, false).unwrap();
let calls = engine.calls.borrow();
calls
.iter()
.filter_map(|c| match c {
MockCall::Sql { sql_content, .. } => Some(sql_content.clone()),
MockCall::Command { command, .. } => Some(command.clone()),
MockCall::Preflight | MockCall::Platform | MockCall::Install { .. } => None,
})
.collect()
}
fn glob_read_project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n \
- name: fetch\n command: \"fetch-into build/src\"\n \
produces: [src_raw]\n preconditions:\n \
- modified_after: { path: build/src, period: 24h }\n \
- name: load\n sql: models/load.sql\n depends_on: [src_raw]\n";
setup_project(
dir.path(),
yaml,
&[(
"models/load.sql",
"CREATE OR REPLACE TABLE t AS SELECT * FROM read_csv('build/src/*.csv');",
)],
);
fs::create_dir_all(dir.path().join("build/src")).unwrap();
fs::write(
dir.path().join("build/src/data.csv"),
b"lei,name\nA,Alpha\n",
)
.unwrap();
dir
}
const LOAD_SQL: &str =
"CREATE OR REPLACE TABLE t AS SELECT * FROM read_csv('build/src/*.csv');";
#[test]
fn test_glob_read_change_forces_the_reading_step_stale() {
let dir = glob_read_project();
let state = MockStateBackend::new();
let csv = dir.path().join("build/src/data.csv");
executed_bodies(dir.path(), &state);
for warm in 1..=2 {
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"warm run {warm}: nothing changed, so nothing may execute"
);
}
for (case, mutate) in [
(
"append",
(|p: &Path| {
let mut existing = fs::read(p).unwrap();
existing.extend_from_slice(b"B,Beta\n");
fs::write(p, existing).unwrap();
}) as fn(&Path),
),
("truncate", |p: &Path| fs::write(p, b"").unwrap()),
("delete", |p: &Path| fs::remove_file(p).unwrap()),
] {
mutate(&csv);
assert_eq!(
executed_bodies(dir.path(), &state),
vec![LOAD_SQL.to_string()],
"{case}: the step reading build/src/*.csv must re-run, and the fetch \
above it must not — its precondition is still fresh and re-running \
it is the expensive answer this change exists not to give"
);
fs::write(&csv, b"lei,name\nA,Alpha\n").unwrap();
executed_bodies(dir.path(), &state);
}
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"control: the re-runs above were the mutations, not the harness"
);
}
fn persisted_fit_project(declare_fit: bool) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let fit_line = if declare_fit {
"\n fit: build/homes.fit"
} else {
""
};
let yaml = format!(
"name: test\nsteps:\n - name: project\n op: umap_project@1\n with:\n \
input: build/homes.parquet\n columns: [longitude, latitude]\n \
out: build/mapped.parquet{fit_line}\n"
);
setup_project(dir.path(), &yaml, &[]);
fs::create_dir_all(dir.path().join("build")).unwrap();
fs::write(dir.path().join("build/homes.parquet"), b"PAR1-input").unwrap();
fs::write(dir.path().join("build/mapped.parquet"), b"PAR1-output").unwrap();
fs::write(dir.path().join("build/homes.fit"), b"pickled-reducer").unwrap();
dir
}
fn settle(dir: &Path, state: &MockStateBackend) {
let manifest = crate::manifest::Manifest::load(dir).unwrap();
let asset_graph = AssetGraph::build(&manifest, dir);
let all_produced = all_produced_assets(&asset_graph);
for step in &manifest.steps {
let artifact = produced_artifact_hash(
step,
dir,
&asset_graph,
&all_produced,
Unreadable::for_step(step),
)
.unwrap_or_else(|| "MISSING".to_string());
state
.record_step(
&step.name,
&op_config_hash(step),
&artifact,
StepStatus::Success,
)
.unwrap();
}
}
fn project_is_stale(dir: &Path, state: &MockStateBackend) -> bool {
let manifest = crate::manifest::Manifest::load(dir).unwrap();
let asset_graph = AssetGraph::build(&manifest, dir);
let all_produced = all_produced_assets(&asset_graph);
let env = HashMap::new();
compute_staleness(
&manifest,
dir,
state,
&asset_graph,
&all_produced,
false,
&env,
)
.unwrap()
.stale
.contains("project")
}
#[test]
fn a_declared_fit_makes_the_second_run_find_it_and_a_missing_one_refit() {
let dir = persisted_fit_project(true);
let state = MockStateBackend::new();
let fit = dir.path().join("build/homes.fit");
settle(dir.path(), &state);
assert!(
!project_is_stale(dir.path(), &state),
"nothing changed, so the second run must find the fit and skip — a step \
that goes stale here refits on every run and the map never holds still"
);
fs::remove_file(&fit).unwrap();
assert!(
project_is_stale(dir.path(), &state),
"a fit that is GONE must send the step back to work. Reporting clean here \
would leave the graph asserting a fitted projection exists while nothing \
is on disk, and the next append would have nothing to place into"
);
fs::write(&fit, b"pickled-reducer").unwrap();
assert!(
!project_is_stale(dir.path(), &state),
"control: putting the same bytes back settles the step again, so the \
re-run above was the missing file and not the harness"
);
fs::write(&fit, b"pickled-reducer-but-edited").unwrap();
assert!(
project_is_stale(dir.path(), &state),
"a fit whose BYTES moved is a different layout, and a step that skipped \
here would ship coordinates drawn from a projection its own state has \
never seen"
);
}
#[test]
fn an_undeclared_fit_is_a_file_arc_cannot_see() {
let dir = persisted_fit_project(false);
let state = MockStateBackend::new();
settle(dir.path(), &state);
assert!(
!project_is_stale(dir.path(), &state),
"a settled run with no `fit:` is clean, as it was before the field existed"
);
fs::remove_file(dir.path().join("build/homes.fit")).unwrap();
assert!(
!project_is_stale(dir.path(), &state),
"with no `fit:` declared, deleting the file changes nothing arc can see — \
which is exactly why the declaration is the work here, and not a \
restatement of what the operator already did"
);
}
#[test]
fn a_fit_a_run_claims_to_have_written_and_did_not_is_named() {
let dir = persisted_fit_project(true);
let manifest = crate::manifest::Manifest::load(dir.path()).unwrap();
let asset_graph = AssetGraph::build(&manifest, dir.path());
let step = manifest.steps.iter().find(|s| s.name == "project").unwrap();
assert!(
missing_declared_produces(step, dir.path(), &asset_graph).is_empty(),
"with the fit on disk there is nothing to report"
);
fs::remove_file(dir.path().join("build/homes.fit")).unwrap();
assert_eq!(
missing_declared_produces(step, dir.path(), &asset_graph),
vec!["build/homes.fit".to_string()],
"a step that declared a fit and left none has to be named — by the fit's \
own path, so the reader knows which of the step's outputs is missing"
);
}
fn command_produces_file_project(command: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let yaml = format!(
"name: test\nsteps:\n \
- name: fetch\n command: \"{command}\"\n \
produces: [build/src/data.csv]\n preconditions:\n \
- modified_after: {{ path: build/src/data.csv, period: 24h }}\n \
- name: load\n sql: models/load.sql\n depends_on: [build/src/data.csv]\n"
);
setup_project(
dir.path(),
&yaml,
&[("models/load.sql", "CREATE OR REPLACE TABLE t AS SELECT 1;")],
);
fs::create_dir_all(dir.path().join("build/src")).unwrap();
fs::write(
dir.path().join("build/src/data.csv"),
b"lei,name\nA,Alpha\n",
)
.unwrap();
age_file(&dir.path().join("build/src/data.csv"), 48 * 3600);
dir
}
fn age_file(path: &Path, seconds: u64) {
let when = std::time::SystemTime::now() - std::time::Duration::from_secs(seconds);
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(when)
.unwrap();
}
#[test]
fn test_command_step_produced_file_change_forces_it_and_its_consumer_stale() {
let dir = command_produces_file_project("fetch-into build/src");
let state = MockStateBackend::new();
let csv = dir.path().join("build/src/data.csv");
let fetch = "fetch-into build/src".to_string();
let load = "CREATE OR REPLACE TABLE t AS SELECT 1;".to_string();
assert_eq!(
executed_bodies(dir.path(), &state),
vec![fetch.clone(), load.clone()],
"run 1: the precondition is stale, so the step runs and records a baseline"
);
age_file(&csv, 0);
for warm in 1..=2 {
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"warm run {warm}: a settled command step must skip"
);
}
for (case, mutate) in [
(
"rewrite",
(|p: &Path| fs::write(p, b"lei,name\nZ,Zeta\n").unwrap()) as fn(&Path),
),
("truncate", |p: &Path| fs::write(p, b"").unwrap()),
("delete", |p: &Path| fs::remove_file(p).unwrap()),
] {
mutate(&csv);
assert_eq!(
executed_bodies(dir.path(), &state),
vec![fetch.clone(), load.clone()],
"{case}: the command step that declared build/src/data.csv must re-run, \
and the sql step reading it must be dragged with it"
);
fs::write(&csv, b"lei,name\nA,Alpha\n").unwrap();
executed_bodies(dir.path(), &state);
}
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"control: untouched, it settles"
);
}
#[test]
fn test_command_line_edit_forces_the_command_step_stale() {
let dir = command_produces_file_project("fetch-into build/src");
let state = MockStateBackend::new();
executed_bodies(dir.path(), &state);
age_file(&dir.path().join("build/src/data.csv"), 0);
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"settled"
);
let yaml = fs::read_to_string(dir.path().join("arcform.yaml")).unwrap();
fs::write(
dir.path().join("arcform.yaml"),
yaml.replace("fetch-into build/src", "fetch-into build/src --v2"),
)
.unwrap();
assert_eq!(
executed_bodies(dir.path(), &state),
vec![
"fetch-into build/src --v2".to_string(),
"CREATE OR REPLACE TABLE t AS SELECT 1;".to_string()
],
"the edited command line must re-run its step"
);
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"and settle again on the new command line, rather than re-running forever"
);
}
#[test]
fn test_an_ordering_token_on_a_command_step_does_not_force_it_to_refetch() {
let dir = glob_read_project();
let state = MockStateBackend::new();
executed_bodies(dir.path(), &state);
for warm in 1..=3 {
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"warm run {warm}: `produces: [src_raw]` names nothing on disk, and that \
must leave the fetch's skip to its precondition rather than turning \
every run into a re-fetch"
);
}
}
fn ordering_token_project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n \
- name: fetch\n command: \"fetch-into build/src\"\n \
produces: [src_raw]\n preconditions:\n \
- modified_after: { path: build/src/data.csv, period: 24h }\n";
setup_project(dir.path(), yaml, &[]);
fs::create_dir_all(dir.path().join("build/src")).unwrap();
fs::write(
dir.path().join("build/src/data.csv"),
b"lei,name\nA,Alpha\n",
)
.unwrap();
age_file(&dir.path().join("build/src/data.csv"), 48 * 3600);
dir
}
#[test]
fn test_an_ordering_token_command_step_with_prior_state_still_skips() {
let dir = ordering_token_project();
let state = MockStateBackend::new();
let fetch = "fetch-into build/src".to_string();
assert_eq!(
executed_bodies(dir.path(), &state),
vec![fetch.clone()],
"run 1: the precondition is stale, so the step runs and records a baseline"
);
age_file(&dir.path().join("build/src/data.csv"), 0);
for warm in 1..=3 {
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"warm run {warm}: an unreadable `produces:` on a command step leaves the \
skip to its precondition rather than turning every run into a re-fetch"
);
}
let real = command_produces_file_project("fetch-into build/src");
let real_state = MockStateBackend::new();
executed_bodies(real.path(), &real_state);
age_file(&real.path().join("build/src/data.csv"), 0);
assert_eq!(
executed_bodies(real.path(), &real_state),
Vec::<String>::new()
);
fs::write(real.path().join("build/src/data.csv"), b"changed").unwrap();
assert_eq!(
executed_bodies(real.path(), &real_state).first(),
Some(&"fetch-into build/src".to_string()),
"control: a readable declared produces: is still hashed and still bites"
);
}
#[test]
fn every_precondition_gated_command_step_in_the_examples_declares_an_ordering_token() {
let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
let mut dirs: Vec<std::path::PathBuf> = fs::read_dir(&examples)
.expect("examples/ is checked in")
.map(|e| e.unwrap().path())
.filter(|p| p.join("arcform.yaml").is_file())
.collect();
dirs.sort();
assert!(
!dirs.is_empty(),
"examples/ must hold at least one protocol"
);
let mut gated = 0usize;
let mut tokens = 0usize;
for d in &dirs {
let manifest = crate::manifest::Manifest::load(d)
.unwrap_or_else(|e| panic!("{} does not load: {e}", d.display()));
for step in &manifest.steps {
if step.command.is_none() || step.preconditions.is_empty() {
continue;
}
gated += 1;
if !step.produces.is_empty()
&& step.produces.iter().all(|p| !p.contains(['/', '\\']))
{
tokens += 1;
}
}
}
assert_eq!(
gated, 9,
"precondition-gated command steps across examples/"
);
assert_eq!(
tokens, gated,
"every one of them declares a separator-free `produces:` — the ordering \
token shape `Unreadable::LeavesTheNameOut` is built around"
);
}
fn trailing_separator_project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let yaml = "name: test\nsteps:\n \
- name: emit\n sql: models/emit.sql\n \
produces: [build/parts_out/]\n \
- name: consume\n sql: models/consume.sql\n \
depends_on: [build/parts_in/]\n";
setup_project(
dir.path(),
yaml,
&[
(
"models/emit.sql",
"CREATE OR REPLACE TABLE emitted AS SELECT 1;",
),
(
"models/consume.sql",
"CREATE OR REPLACE TABLE consumed AS SELECT 2;",
),
],
);
fs::create_dir_all(dir.path().join("build/parts_out")).unwrap();
fs::create_dir_all(dir.path().join("build/parts_in")).unwrap();
fs::write(dir.path().join("build/parts_out/year=2024.parquet"), b"out").unwrap();
fs::write(dir.path().join("build/parts_in/year=2024.parquet"), b"in").unwrap();
dir
}
const EMIT: &str = "CREATE OR REPLACE TABLE emitted AS SELECT 1;";
const CONSUME: &str = "CREATE OR REPLACE TABLE consumed AS SELECT 2;";
#[test]
fn test_a_trailing_separator_asset_settles_through_a_real_run() {
let dir = trailing_separator_project();
let state = MockStateBackend::new();
assert_eq!(
executed_bodies(dir.path(), &state),
vec![EMIT.to_string(), CONSUME.to_string()],
"run 1: nothing has run yet"
);
for warm in 2..=5 {
assert_eq!(
executed_bodies(dir.path(), &state),
Vec::<String>::new(),
"run {warm}: `build/parts_out/` and `build/parts_in/` are directories \
that are there and unchanged, so neither step may execute"
);
}
}
#[test]
fn test_a_trailing_separator_asset_is_still_hashed_by_its_contents() {
let dir = trailing_separator_project();
let state = MockStateBackend::new();
executed_bodies(dir.path(), &state);
assert_eq!(executed_bodies(dir.path(), &state), Vec::<String>::new());
fs::write(dir.path().join("build/parts_in/year=2025.parquet"), b"in2").unwrap();
assert_eq!(
executed_bodies(dir.path(), &state),
vec![CONSUME.to_string()],
"a file arriving in build/parts_in/ re-runs the step that declares it, and \
only that step"
);
assert_eq!(executed_bodies(dir.path(), &state), Vec::<String>::new());
fs::write(
dir.path().join("build/parts_out/year=2024.parquet"),
b"rewritten",
)
.unwrap();
assert_eq!(
executed_bodies(dir.path(), &state),
vec![EMIT.to_string()],
"rewriting a file in build/parts_out/ re-runs the step that produced it"
);
assert_eq!(executed_bodies(dir.path(), &state), Vec::<String>::new());
fs::remove_file(dir.path().join("build/parts_in/year=2024.parquet")).unwrap();
fs::remove_file(dir.path().join("build/parts_in/year=2025.parquet")).unwrap();
assert!(dir.path().join("build/parts_in").is_dir());
assert_eq!(
executed_bodies(dir.path(), &state),
vec![CONSUME.to_string()],
"an emptied but surviving directory is a change to what the step reads"
);
}
#[test]
fn every_asset_kind_answers_whether_the_staleness_path_hashes_it() {
for kind in [
AssetKind::File,
AssetKind::Directory,
AssetKind::Pattern,
AssetKind::Table,
] {
let expected = match kind {
AssetKind::File | AssetKind::Directory | AssetKind::Pattern => true,
AssetKind::Table => false,
};
assert_eq!(
kind_is_hashed(kind),
expected,
"{kind:?} must keep the answer it was given deliberately"
);
}
}
fn only_contract(dir: &Path) -> crate::contract::Contract {
let runs = dir.join("build/.arcform/runs");
let json = fs::read_dir(&runs)
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.extension().is_some_and(|x| x == "json"))
.expect("the run wrote its contract");
serde_json::from_str(&fs::read_to_string(json).unwrap()).unwrap()
}
#[test]
fn the_record_names_what_the_duckdb_the_steps_ran_on_reports() {
use crate::contract::ExtensionEntry;
use crate::engine::{EngineReport, ReportedExtension};
let dir = tempfile::tempdir().unwrap();
setup_project(
dir.path(),
"name: rec\ndb: rec.duckdb\nsteps:\n - name: s\n sql: models/s.sql\nhooks:\n on_exit:\n name: bye\n sql: models/bye.sql\n",
&[
(
"models/s.sql",
"INSTALL httpfs;\nCREATE TABLE t AS SELECT 1;\n",
),
("models/bye.sql", "INSTALL spatial FROM core;\n"),
],
);
let file = dir.path().join("httpfs.duckdb_extension");
fs::write(&file, b"a build").unwrap();
let hash = crate::fetch_cache::hash_file(&file).unwrap();
let engine = MockEngine::new();
*engine.report.borrow_mut() = EngineReport {
version: Some("v1.5.3".into()),
platform: Some("linux_arm64".into()),
extensions: [(
"httpfs".to_string(),
ReportedExtension {
path: Some(file),
repository: Some("core".into()),
version: Some("827222f".into()),
},
)]
.into_iter()
.collect(),
};
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert_eq!(
*engine.reported.borrow(),
vec![vec!["httpfs".to_string(), "spatial".to_string()]],
"asked once, about each extension the SQL installs"
);
let contract = only_contract(dir.path());
let engine_info = &contract.run.engine;
assert_eq!(engine_info.arc, env!("CARGO_PKG_VERSION"));
assert_eq!(engine_info.duckdb_cli.as_deref(), Some("v1.5.3"));
assert_eq!(engine_info.platform.as_deref(), Some("linux_arm64"));
assert_eq!(
engine_info.extensions,
Some(vec![
ExtensionEntry {
name: "httpfs".into(),
repository: Some("core".into()),
version: Some("827222f".into()),
sha256: Some(hash),
},
ExtensionEntry {
name: "spatial".into(),
repository: None,
version: None,
sha256: None,
},
])
);
}
#[test]
fn a_protocol_with_no_sql_asks_duckdb_nothing_and_records_neither() {
let dir = tempfile::tempdir().unwrap();
setup_project(
dir.path(),
"name: rec\ndb: rec.duckdb\nsteps:\n - name: c\n command: echo hi\n",
&[],
);
let engine = MockEngine::new();
*engine.report.borrow_mut() = crate::engine::EngineReport {
version: Some("v1.5.3".into()),
platform: Some("linux_arm64".into()),
extensions: HashMap::new(),
};
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
assert!(engine.reported.borrow().is_empty(), "DuckDB was not asked");
let engine_info = only_contract(dir.path()).run.engine;
assert_eq!(engine_info.duckdb_cli, None);
assert_eq!(engine_info.platform, None);
assert_eq!(engine_info.extensions, None);
}
#[test]
fn the_record_names_each_extension_a_sql_step_or_hook_loaded_once_after_the_installed_ones() {
use crate::contract::ExtensionEntry;
use crate::engine::{EngineReport, ReportedExtension};
let dir = tempfile::tempdir().unwrap();
setup_project(
dir.path(),
"name: rec\ndb: rec.duckdb\nsteps:\n - name: s\n sql: models/s.sql\n - name: t\n sql: models/t.sql\n - name: c\n command: echo hi\nhooks:\n on_exit:\n name: bye\n sql: models/bye.sql\n",
&[
("models/s.sql", "INSTALL httpfs;\nLOAD fts;\n"),
("models/t.sql", "SELECT stem('running', 'english');\n"),
("models/bye.sql", "LOAD excel;\n"),
],
);
let reported = |name: &str, version: &str| ReportedExtension {
path: None,
repository: Some(name.to_string()),
version: Some(version.to_string()),
};
let engine = MockEngine::new();
*engine.report.borrow_mut() = EngineReport {
version: Some("v1.5.5".into()),
platform: Some("linux_amd64".into()),
extensions: [("httpfs".to_string(), reported("core", "when the run ended"))]
.into_iter()
.collect(),
};
*engine.loads.borrow_mut() = [
(
"s.sql",
vec![
("httpfs".to_string(), reported("core", "in s")),
("fts".to_string(), reported("core", "in s")),
],
),
(
"t.sql",
vec![
("fts".to_string(), reported("core", "in t")),
("spatial".to_string(), reported("core", "in t")),
],
),
(
"bye.sql",
vec![("excel".to_string(), reported("core", "in bye"))],
),
]
.into_iter()
.map(|(file, answer)| (file.to_string(), answer))
.collect();
let state = MockStateBackend::new();
run(dir.path(), &engine, &state, false).unwrap();
let entry = |name: &str, version: &str| ExtensionEntry {
name: name.into(),
repository: Some("core".into()),
version: Some(version.into()),
sha256: None,
};
assert_eq!(
only_contract(dir.path()).run.engine.extensions,
Some(vec![
entry("httpfs", "when the run ended"),
entry("fts", "in s"),
entry("spatial", "in t"),
entry("excel", "in bye"),
])
);
}
}