use std::path::PathBuf;
use std::time::Duration;
use tempfile::TempDir;
use baraddur::App;
use baraddur::RunOnceOptions;
use baraddur::config::{Config, OnFailureConfig, OutputConfig, Step, WatchConfig};
use baraddur::output::{DisplayConfig, Verbosity};
fn trivial_app(td: &TempDir, step_cmd: &str) -> App {
let root = td.path().to_path_buf();
let config = Config {
watch: WatchConfig {
extensions: vec!["rs".into()],
debounce_ms: 100,
ignore: vec![],
},
output: OutputConfig::default(),
on_failure: OnFailureConfig::default(),
steps: vec![Step {
name: "noop".into(),
cmd: step_cmd.into(),
parallel: false,
if_changed: Vec::new(),
}],
profiles: std::collections::HashMap::new(),
};
App {
config,
config_path: root.join(".baraddur.toml"),
root,
display_config: DisplayConfig {
is_tty: false,
no_clear: true,
verbosity: Verbosity::Quiet,
format: baraddur::output::OutputFormat::Auto,
},
profile: None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_until_exits_on_stop_signal() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
let app = trivial_app(&td, "true");
let stop = async {
tokio::time::sleep(Duration::from_millis(400)).await;
};
let result = tokio::time::timeout(Duration::from_secs(5), app.run_until(stop))
.await
.expect("run_until did not return within 5s");
result.expect("run_until returned an error");
let log = root.join(".baraddur").join("last-run.log");
assert!(
log.exists(),
"expected {} to exist after one pipeline run",
log.display()
);
let contents = std::fs::read_to_string(&log).unwrap();
assert!(
contents.contains("noop"),
"log should mention the step name; got:\n{contents}"
);
assert!(
contents.contains("pass"),
"log should mark the step as passing; got:\n{contents}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn on_failure_hook_runs_after_failing_run() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
let sentinel = root.join("hook-input.txt");
let mut app = trivial_app(&td, "false");
app.config.on_failure = OnFailureConfig {
enabled: true,
cmd: format!("tee {}", sentinel.display()),
prompt: "PROMPT_LINE".into(),
timeout_secs: 5,
};
let stop = async {
tokio::time::sleep(Duration::from_millis(800)).await;
};
let _ = tokio::time::timeout(Duration::from_secs(10), app.run_until(stop))
.await
.expect("run_until did not return within 10s");
let captured = std::fs::read_to_string(&sentinel)
.unwrap_or_else(|_| panic!("expected {} to exist", sentinel.display()));
assert!(
captured.contains("PROMPT_LINE"),
"hook stdin missing prompt prefix; got:\n{captured}"
);
assert!(
captured.contains("noop"),
"hook stdin missing failed step name; got:\n{captured}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_until_records_failures() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
let app = trivial_app(&td, "false");
let stop = async {
tokio::time::sleep(Duration::from_millis(400)).await;
};
let _ = tokio::time::timeout(Duration::from_secs(5), app.run_until(stop))
.await
.expect("run_until did not return within 5s");
let log = root.join(".baraddur").join("last-run.log");
let contents = std::fs::read_to_string(&log).unwrap();
assert!(
contents.contains("FAIL"),
"log should mark the step as failing; got:\n{contents}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_once_returns_success_on_passing_pipeline() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
let app = trivial_app(&td, "true");
let success = tokio::time::timeout(
Duration::from_secs(5),
app.run_once(RunOnceOptions::default()),
)
.await
.expect("run_once did not return within 5s")
.expect("run_once returned an error");
assert!(
success,
"run_once should return true for a passing pipeline"
);
let log = root.join(".baraddur").join("last-run.log");
assert!(log.exists(), "expected {} to exist", log.display());
let contents = std::fs::read_to_string(&log).unwrap();
assert!(contents.contains("noop"));
assert!(contents.contains("pass"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_once_with_staged_paths_filters_step_subset() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
git(&root, &["init", "-q"]);
git(&root, &["config", "user.email", "test@example.com"]);
git(&root, &["config", "user.name", "test"]);
std::fs::write(root.join("foo.rs"), "fn x() {}\n").unwrap();
std::fs::write(root.join("notes.md"), "# notes\n").unwrap();
git(&root, &["add", "foo.rs"]);
let staged = baraddur::git::staged_paths(&root).await.unwrap();
assert_eq!(staged, vec![PathBuf::from("foo.rs")]);
let app = App {
config: Config {
watch: WatchConfig {
extensions: vec!["rs".into()],
debounce_ms: 100,
ignore: vec![],
},
output: OutputConfig::default(),
on_failure: OnFailureConfig::default(),
steps: vec![
Step {
name: "rust".into(),
cmd: "true".into(),
parallel: false,
if_changed: vec!["**/*.rs".into()],
},
Step {
name: "docs".into(),
cmd: "true".into(),
parallel: false,
if_changed: vec!["**/*.md".into()],
},
],
profiles: std::collections::HashMap::new(),
},
config_path: root.join(".baraddur.toml"),
root: root.clone(),
display_config: DisplayConfig {
is_tty: false,
no_clear: true,
verbosity: Verbosity::Quiet,
format: baraddur::output::OutputFormat::Auto,
},
profile: None,
};
let success = app
.run_once(RunOnceOptions {
no_hook: false,
initial_trigger: Some(staged),
})
.await
.unwrap();
assert!(success);
let log = std::fs::read_to_string(root.join(".baraddur").join("last-run.log")).unwrap();
assert!(
log.contains("rust"),
"expected `rust` step to run; log:\n{log}"
);
assert!(
!log.contains("docs"),
"expected `docs` step to be filtered out; log:\n{log}"
);
}
fn git(cwd: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn git_stdout(cwd: &std::path::Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_once_runs_on_failure_hook() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
let sentinel = root.join("hook-input.txt");
let mut app = trivial_app(&td, "false");
app.config.on_failure = OnFailureConfig {
enabled: true,
cmd: format!("tee {}", sentinel.display()),
prompt: "PROMPT_LINE".into(),
timeout_secs: 5,
};
let success = tokio::time::timeout(
Duration::from_secs(10),
app.run_once(RunOnceOptions::default()),
)
.await
.expect("run_once did not return within 10s")
.expect("run_once returned an error");
assert!(!success);
let captured = std::fs::read_to_string(&sentinel)
.unwrap_or_else(|_| panic!("expected hook to write {}", sentinel.display()));
assert!(
captured.contains("PROMPT_LINE"),
"hook stdin missing prompt prefix; got:\n{captured}"
);
assert!(
captured.contains("noop"),
"hook stdin missing failed step name; got:\n{captured}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_once_no_hook_skips_on_failure_hook() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
let sentinel = root.join("hook-input.txt");
let mut app = trivial_app(&td, "false");
app.config.on_failure = OnFailureConfig {
enabled: true,
cmd: format!("tee {}", sentinel.display()),
prompt: "PROMPT_LINE".into(),
timeout_secs: 5,
};
let success = tokio::time::timeout(
Duration::from_secs(10),
app.run_once(RunOnceOptions {
no_hook: true,
initial_trigger: None,
}),
)
.await
.expect("run_once did not return within 10s")
.expect("run_once returned an error");
assert!(!success);
assert!(
!sentinel.exists(),
"hook ran despite no_hook = true: {} exists",
sentinel.display()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn diff_since_includes_committed_changes_and_untracked_but_not_ignored() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
git(&root, &["init", "-q"]);
git(&root, &["config", "user.email", "test@example.com"]);
git(&root, &["config", "user.name", "test"]);
std::fs::write(root.join(".gitignore"), "ignored.rs\n").unwrap();
std::fs::write(root.join("a.rs"), "fn a() {}\n").unwrap();
git(&root, &["add", ".gitignore", "a.rs"]);
git(&root, &["commit", "-q", "-m", "baseline"]);
let baseline = git_stdout(&root, &["rev-parse", "HEAD"]);
let baseline = baseline.trim();
std::fs::write(root.join("a.rs"), "fn a() { let _ = 1; }\n").unwrap();
git(&root, &["commit", "-q", "-am", "modify a"]);
std::fs::write(root.join("b.rs"), "fn b() {}\n").unwrap();
std::fs::write(root.join("ignored.rs"), "fn ignored() {}\n").unwrap();
let mut paths = baraddur::git::diff_since(&root, baseline).await.unwrap();
paths.sort();
assert_eq!(
paths,
vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")],
"expected committed-change `a.rs` + untracked `b.rs`; `ignored.rs` must be excluded"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_once_returns_failure_on_failing_step() {
let td = TempDir::new().unwrap();
let root = td.path().to_path_buf();
let app = trivial_app(&td, "false");
let success = tokio::time::timeout(
Duration::from_secs(5),
app.run_once(RunOnceOptions::default()),
)
.await
.expect("run_once did not return within 5s")
.expect("run_once returned an error");
assert!(!success, "run_once should return false when a step fails");
let log = root.join(".baraddur").join("last-run.log");
let contents = std::fs::read_to_string(&log).unwrap();
assert!(contents.contains("FAIL"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_until_exits_promptly() {
let td = TempDir::new().unwrap();
let app = trivial_app(&td, "true");
let start = std::time::Instant::now();
let stop = async {
tokio::time::sleep(Duration::from_millis(50)).await;
};
let _ = tokio::time::timeout(Duration::from_secs(5), app.run_until(stop))
.await
.expect("run_until did not return within 5s");
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"expected prompt shutdown; took {elapsed:?}"
);
}