#![forbid(unsafe_code)]
pub mod config;
pub mod git;
mod loop_guard;
pub mod output;
pub mod pipeline;
pub mod watcher;
use anyhow::Result;
use std::fmt::Write as _;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tokio::task::JoinHandle;
use crate::loop_guard::LoopGuard;
use crate::output::style::{Theme, should_color};
use crate::output::{
BrowseAction, Display, DisplayConfig, JsonDisplay, OutputFormat, PlainDisplay, TtyDisplay,
};
use crate::pipeline::StepResult;
macro_rules! debug_log {
($dc:expr, $($arg:tt)*) => {
if $dc.verbosity == $crate::output::Verbosity::Debug {
eprintln!(
"[debug {}] {}",
::chrono::Local::now().format("%H:%M:%S%.3f"),
format_args!($($arg)*)
);
}
};
}
const LOOP_WINDOW: Duration = Duration::from_secs(10);
const LOOP_THRESHOLD: usize = 5;
const LOOP_COOLDOWN: Duration = Duration::from_secs(5);
type HookHandle = JoinHandle<Result<Option<String>>>;
pub struct App {
pub config: config::Config,
pub config_path: PathBuf,
pub root: PathBuf,
pub display_config: DisplayConfig,
pub profile: Option<String>,
}
pub fn apply_profile(cfg: &mut config::Config, profile_name: &str) -> Result<()> {
let members = cfg.profiles.get(profile_name).ok_or_else(|| {
let mut available: Vec<&String> = cfg.profiles.keys().collect();
available.sort();
if available.is_empty() {
anyhow::anyhow!("profile `{profile_name}` not found (no profiles defined in config)")
} else {
let list = available
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!("profile `{profile_name}` not found (defined: {list})")
}
})?;
let wanted: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect();
cfg.steps.retain(|s| wanted.contains(s.name.as_str()));
if cfg.steps.is_empty() {
anyhow::bail!("profile `{profile_name}` resolved to zero steps");
}
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct RunOnceOptions {
pub no_hook: bool,
pub initial_trigger: Option<Vec<PathBuf>>,
}
impl App {
pub async fn run(self) -> Result<()> {
let stop = async {
let _ = tokio::signal::ctrl_c().await;
};
self.run_until(stop).await
}
fn build_display(&self) -> Box<dyn Display> {
let dc = &self.display_config;
if dc.format == OutputFormat::Json {
return Box::new(JsonDisplay::new());
}
let color = should_color(dc.is_tty);
if dc.is_tty {
Box::new(TtyDisplay::new(
Theme::new(color),
dc.verbosity,
dc.no_clear,
))
} else {
Box::new(PlainDisplay::new(Theme::new(color), dc.verbosity))
}
}
pub async fn run_once(self, opts: RunOnceOptions) -> Result<bool> {
let dc = &self.display_config;
let mut display: Box<dyn Display> = if dc.format == OutputFormat::Json {
Box::new(JsonDisplay::new())
} else {
let color = should_color(dc.is_tty);
Box::new(PlainDisplay::new(Theme::new(color), dc.verbosity))
};
display.banner(
&self.root,
&self.config_path,
self.config.steps.len(),
self.profile.as_deref(),
);
if let Some(paths) = opts.initial_trigger.as_deref() {
display.set_trigger(paths);
}
let results = pipeline::run_pipeline(
&self.config,
&self.root,
display.as_mut(),
None,
opts.initial_trigger.as_deref(),
None,
)
.await?;
log_run_outcome(write_run_log(&self.root, &results), dc);
let success = results.iter().all(|r| r.success);
if !success && !opts.no_hook && self.config.on_failure.enabled {
display.hook_started();
let combined = pipeline::combine_failed_output(&results);
match pipeline::run_hook(&self.config.on_failure, &self.root, &combined).await {
Ok(Some(text)) => display.hook_output(&text),
_ => display.hook_finished(),
}
}
Ok(success)
}
pub async fn gate(mut self, wrapped: Vec<String>, opts: RunOnceOptions) -> Result<i32> {
if wrapped.is_empty() {
anyhow::bail!("gate: no command provided");
}
let skip_pipeline = matches!(&opts.initial_trigger, Some(paths) if paths.is_empty());
if !skip_pipeline {
self.config.on_failure.timeout_secs = self.config.on_failure.timeout_secs.min(15);
let success = self.run_once(opts).await?;
if !success {
return Ok(1);
}
}
exec_wrapped(&wrapped)
}
#[allow(unused_assignments)]
pub async fn run_until<F>(self, stop: F) -> Result<()>
where
F: Future<Output = ()>,
{
tokio::pin!(stop);
let dc = &self.display_config;
let spinner_interval = if dc.is_tty {
Some(Duration::from_millis(80))
} else {
None
};
let mut display: Box<dyn Display> = self.build_display();
display.banner(
&self.root,
&self.config_path,
self.config.steps.len(),
self.profile.as_deref(),
);
let wcfg = watcher::WatchConfig {
root: self.root.clone(),
debounce: Duration::from_millis(self.config.watch.debounce_ms),
extensions: self.config.watch.extensions.clone(),
ignore: self.config.watch.ignore.clone(),
};
let mut rx = watcher::start(wcfg)?;
let mut key_rx = if dc.is_tty {
Some(spawn_key_reader())
} else {
None
};
debug_log!(dc, "watcher started, running initial pipeline");
let mut hook_handle: Option<HookHandle> = None;
let mut trigger_paths: Option<Vec<PathBuf>> = None;
let mut last_failed_steps: Option<Vec<String>> = None;
let mut rerun_filter: Option<Vec<String>> = None;
let mut guard = LoopGuard::new(LOOP_WINDOW, LOOP_THRESHOLD);
let mut file_triggered = false;
'main: loop {
if file_triggered {
file_triggered = false;
if guard.record(Instant::now()) {
guard.reset();
warn_loop(trigger_paths.as_deref().unwrap_or(&[]));
match cooldown(&mut stop, &mut rx, LOOP_COOLDOWN).await {
Flow::Resume => {}
Flow::Shutdown => {
cancel_hook(&mut hook_handle, display.as_mut());
return self.shutdown();
}
Flow::WatcherDied => {
eprintln!("baraddur: file watcher stopped unexpectedly. exiting.");
return Ok(());
}
}
}
}
let outcome = tokio::select! {
biased;
_ = &mut stop => RunOutcome::Shutdown,
maybe = rx.recv() => {
match maybe {
Some(paths) => RunOutcome::FileChange(paths),
None => RunOutcome::WatcherDied,
}
}
result = pipeline::run_pipeline(
&self.config,
&self.root,
display.as_mut(),
spinner_interval,
trigger_paths.as_deref(),
rerun_filter.as_deref(),
) => RunOutcome::Completed(result),
};
match outcome {
RunOutcome::Completed(result) => {
let results = result?;
log_run_outcome(write_run_log(&self.root, &results), dc);
last_failed_steps = Some(
results
.iter()
.filter(|r| !r.success)
.map(|r| r.name.clone())
.collect(),
);
rerun_filter = None;
cancel_hook(&mut hook_handle, display.as_mut());
if self.config.on_failure.enabled && results.iter().any(|r| !r.success) {
let cfg = self.config.on_failure.clone();
let cwd = self.root.clone();
let combined = pipeline::combine_failed_output(&results);
hook_handle = Some(tokio::spawn(async move {
pipeline::run_hook(&cfg, &cwd, &combined).await
}));
display.hook_started();
}
}
RunOutcome::FileChange(paths) => {
cancel_hook(&mut hook_handle, display.as_mut());
display.run_cancelled();
trigger_paths = Some(self.on_file_change(&paths, &mut rx, display.as_mut()));
file_triggered = true;
continue;
}
RunOutcome::Shutdown => {
cancel_hook(&mut hook_handle, display.as_mut());
return self.shutdown();
}
RunOutcome::WatcherDied => {
eprintln!("baraddur: file watcher stopped unexpectedly. exiting.");
return Ok(());
}
}
debug_log!(dc, "idle — waiting for file change");
if dc.is_tty {
let key_rx = key_rx
.as_mut()
.expect("key_rx initialized when dc.is_tty is true");
while key_rx.try_recv().is_ok() {}
display.enter_browse_mode();
loop {
tokio::select! {
biased;
_ = &mut stop => {
cancel_hook(&mut hook_handle, display.as_mut());
display.exit_browse_mode();
return self.shutdown();
}
maybe = rx.recv() => {
cancel_hook(&mut hook_handle, display.as_mut());
display.exit_browse_mode();
match maybe {
Some(paths) => {
trigger_paths = Some(self.on_file_change(&paths, &mut rx, display.as_mut()));
file_triggered = true;
continue 'main;
}
None => {
eprintln!("baraddur: file watcher stopped unexpectedly. exiting.");
return Ok(());
}
}
}
maybe_key = key_rx.recv() => {
match maybe_key {
Some(key) => match display.handle_key(key) {
BrowseAction::Noop => {}
BrowseAction::Redraw => display.browse_redraw_if_active(),
BrowseAction::Quit => {
cancel_hook(&mut hook_handle, display.as_mut());
display.exit_browse_mode();
return self.shutdown();
}
action @ (BrowseAction::Rerun
| BrowseAction::RerunFailed
| BrowseAction::RerunCursor(_)) => {
cancel_hook(&mut hook_handle, display.as_mut());
display.exit_browse_mode();
let (tp, rf) =
Self::rerun_params(&action, &last_failed_steps);
trigger_paths = tp;
rerun_filter = rf;
continue 'main;
}
},
None => {
display.exit_browse_mode();
eprintln!("baraddur: keyboard reader stopped unexpectedly. exiting.");
return Ok(());
}
}
}
res = await_hook(&mut hook_handle), if hook_handle.is_some() => {
hook_handle = None;
apply_hook_result(res, display.as_mut());
}
}
}
}
'idle: loop {
tokio::select! {
biased;
_ = &mut stop => {
cancel_hook(&mut hook_handle, display.as_mut());
return self.shutdown();
}
maybe = rx.recv() => {
cancel_hook(&mut hook_handle, display.as_mut());
match maybe {
Some(paths) => {
trigger_paths = Some(self.on_file_change(&paths, &mut rx, display.as_mut()));
file_triggered = true;
break 'idle; }
None => {
eprintln!("baraddur: file watcher stopped unexpectedly. exiting.");
return Ok(());
}
}
}
res = await_hook(&mut hook_handle), if hook_handle.is_some() => {
hook_handle = None;
apply_hook_result(res, display.as_mut());
}
}
}
}
}
fn on_file_change(
&self,
paths: &[PathBuf],
rx: &mut tokio::sync::mpsc::Receiver<watcher::WatchEvent>,
display: &mut dyn Display,
) -> Vec<PathBuf> {
while rx.try_recv().is_ok() {}
let dc = &self.display_config;
debug_log!(dc, "file change — triggering pipeline");
for p in paths {
debug_log!(dc, " triggered by: {}", p.display());
}
let rel = rel_paths(paths, &self.root);
display.set_trigger(&rel);
rel
}
fn rerun_params(
action: &BrowseAction,
last_failed: &Option<Vec<String>>,
) -> (Option<Vec<PathBuf>>, Option<Vec<String>>) {
let rerun_filter = match action {
BrowseAction::RerunFailed => last_failed.clone(),
BrowseAction::RerunCursor(name) => Some(vec![name.clone()]),
_ => None,
};
(None, rerun_filter)
}
fn shutdown(&self) -> Result<()> {
eprintln!("\nbaraddur: exiting...");
tokio::spawn(async {
tokio::signal::ctrl_c().await.ok();
eprintln!("baraddur: force exit.");
std::process::exit(130);
});
Ok(())
}
}
async fn await_hook(
handle: &mut Option<HookHandle>,
) -> std::result::Result<Result<Option<String>>, tokio::task::JoinError> {
match handle.as_mut() {
Some(h) => h.await,
None => std::future::pending().await,
}
}
fn cancel_hook(hook_handle: &mut Option<HookHandle>, display: &mut dyn Display) {
if let Some(h) = hook_handle.take() {
h.abort();
display.hook_finished();
}
}
fn apply_hook_result(
res: std::result::Result<Result<Option<String>>, tokio::task::JoinError>,
display: &mut dyn Display,
) {
match res {
Ok(Ok(Some(text))) => display.hook_output(&text),
_ => display.hook_finished(),
}
}
fn spawn_key_reader() -> tokio::sync::mpsc::Receiver<crossterm::event::KeyEvent> {
let (tx, rx) = tokio::sync::mpsc::channel(16);
let _ = std::thread::Builder::new()
.name("baraddur-keys".into())
.spawn(move || {
loop {
match crossterm::event::read() {
Ok(crossterm::event::Event::Key(k)) => {
if tx.blocking_send(k).is_err() {
return;
}
}
Ok(_) => {}
Err(_) => return,
}
}
});
rx
}
fn write_run_log(root: &Path, results: &[StepResult]) -> std::io::Result<PathBuf> {
let log_dir = root.join(".baraddur");
std::fs::create_dir_all(&log_dir)?;
let mut content = String::new();
for r in results {
let _ = writeln!(
content,
"═══ {} ({}) ═══",
r.name,
if r.success { "pass" } else { "FAIL" }
);
if !r.stdout.is_empty() {
content.push_str(&r.stdout);
if !r.stdout.ends_with('\n') {
content.push('\n');
}
}
if !r.stderr.is_empty() {
content.push_str("--- stderr ---\n");
content.push_str(&r.stderr);
if !r.stderr.ends_with('\n') {
content.push('\n');
}
}
content.push('\n');
}
let path = log_dir.join("last-run.log");
std::fs::write(&path, &content)?;
Ok(path)
}
fn log_run_outcome(outcome: std::io::Result<PathBuf>, dc: &DisplayConfig) {
match outcome {
Ok(path) => debug_log!(dc, "wrote run log: {}", path.display()),
Err(e) => eprintln!("baraddur: failed to write .baraddur/last-run.log: {e}"),
}
}
fn warn_loop_lines(paths: &[PathBuf]) -> Vec<String> {
let mut lines = vec![
format!(
"baraddur: restart loop detected — the pipeline restarted {LOOP_THRESHOLD} times within {}s.",
LOOP_WINDOW.as_secs()
),
"baraddur: a step is likely modifying watched files (e.g. a formatter), which the watcher re-detects.".to_string(),
];
if paths.is_empty() {
lines.push("baraddur: (no triggering paths recorded for the latest restart)".to_string());
} else {
lines.push("baraddur: latest restart triggered by:".to_string());
for p in paths.iter().take(10) {
lines.push(format!("baraddur: {}", p.display()));
}
if paths.len() > 10 {
lines.push(format!("baraddur: … and {} more", paths.len() - 10));
}
}
lines.push(format!(
"baraddur: pausing {}s to let changes settle. add these paths to [watch].ignore or fix the step to stop this.",
LOOP_COOLDOWN.as_secs()
));
lines
}
fn warn_loop(paths: &[PathBuf]) {
for line in warn_loop_lines(paths) {
eprintln!("{line}");
}
}
enum Flow {
Resume,
Shutdown,
WatcherDied,
}
async fn cooldown<F>(
stop: &mut std::pin::Pin<&mut F>,
rx: &mut tokio::sync::mpsc::Receiver<watcher::WatchEvent>,
dur: Duration,
) -> Flow
where
F: Future<Output = ()>,
{
let deadline = tokio::time::Instant::now() + dur;
loop {
tokio::select! {
biased;
_ = stop.as_mut() => return Flow::Shutdown,
_ = tokio::time::sleep_until(deadline) => return Flow::Resume,
maybe = rx.recv() => {
match maybe {
Some(_) => { while rx.try_recv().is_ok() {} }
None => return Flow::WatcherDied,
}
}
}
}
}
fn rel_paths(paths: &[PathBuf], root: &Path) -> Vec<PathBuf> {
paths
.iter()
.map(|p| p.strip_prefix(root).unwrap_or(p).to_path_buf())
.collect()
}
fn exec_wrapped(args: &[String]) -> Result<i32> {
let (program, rest) = args
.split_first()
.ok_or_else(|| anyhow::anyhow!("no command provided"))?;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = std::process::Command::new(program).args(rest).exec();
Err(anyhow::Error::new(err).context(format!("exec `{program}`")))
}
#[cfg(not(unix))]
{
use anyhow::Context as _;
let status = std::process::Command::new(program)
.args(rest)
.status()
.with_context(|| format!("spawning `{program}`"))?;
Ok(status.code().unwrap_or(1))
}
}
enum RunOutcome {
Completed(Result<Vec<StepResult>>),
FileChange(Vec<PathBuf>),
Shutdown,
WatcherDied,
}
#[cfg(test)]
mod apply_profile_tests {
use super::*;
use crate::config::{Config, OnFailureConfig, OutputConfig, Step, WatchConfig};
fn step(name: &str) -> Step {
Step {
name: name.into(),
cmd: "true".into(),
parallel: false,
if_changed: Vec::new(),
}
}
fn cfg_with_steps(names: &[&str]) -> Config {
Config {
watch: WatchConfig {
extensions: vec!["rs".into()],
debounce_ms: 1000,
ignore: vec![],
},
output: OutputConfig::default(),
on_failure: OnFailureConfig::default(),
steps: names.iter().map(|n| step(n)).collect(),
profiles: std::collections::HashMap::new(),
}
}
#[test]
fn filters_steps_to_profile_members_preserving_order() {
let mut cfg = cfg_with_steps(&["fmt", "check", "clippy", "test"]);
cfg.profiles
.insert("quick".into(), vec!["check".into(), "fmt".into()]);
apply_profile(&mut cfg, "quick").unwrap();
let names: Vec<&str> = cfg.steps.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["fmt", "check"]);
}
#[test]
fn errors_when_profile_name_unknown() {
let mut cfg = cfg_with_steps(&["fmt"]);
cfg.profiles.insert("quick".into(), vec!["fmt".into()]);
let err = apply_profile(&mut cfg, "missing").unwrap_err();
let s = err.to_string();
assert!(s.contains("profile `missing` not found"), "msg: {s}");
assert!(s.contains("quick"), "expected available list in msg: {s}");
}
#[test]
fn errors_when_no_profiles_defined() {
let mut cfg = cfg_with_steps(&["fmt"]);
let err = apply_profile(&mut cfg, "anything").unwrap_err();
assert!(err.to_string().contains("no profiles defined"));
}
#[test]
fn errors_when_profile_resolves_to_zero_steps() {
let mut cfg = cfg_with_steps(&["fmt"]);
cfg.profiles.insert("ghost".into(), vec!["nope".into()]);
let err = apply_profile(&mut cfg, "ghost").unwrap_err();
assert!(err.to_string().contains("zero steps"));
}
}
#[cfg(test)]
mod run_until_helpers_tests {
use super::*;
use crate::config::{Config, OnFailureConfig, OutputConfig, WatchConfig};
use crate::output::{PlainDisplay, Theme, Verbosity};
fn mk_app(root: PathBuf) -> App {
App {
config: Config {
watch: WatchConfig {
extensions: vec!["rs".into()],
debounce_ms: 1000,
ignore: vec![],
},
output: OutputConfig::default(),
on_failure: OnFailureConfig::default(),
steps: vec![],
profiles: std::collections::HashMap::new(),
},
config_path: root.join(".baraddur.toml"),
root,
display_config: DisplayConfig {
is_tty: false,
no_clear: true,
verbosity: Verbosity::Normal,
format: OutputFormat::Auto,
},
profile: None,
}
}
#[test]
fn rerun_params_full_rerun_clears_paths_and_filter() {
let last = Some(vec!["a".to_string()]);
let (tp, rf) = App::rerun_params(&BrowseAction::Rerun, &last);
assert_eq!(tp, None);
assert_eq!(rf, None, "full rerun ignores the last-failed set");
}
#[test]
fn rerun_params_failed_uses_last_failed_set() {
let last = Some(vec!["check".to_string(), "test".to_string()]);
let (tp, rf) = App::rerun_params(&BrowseAction::RerunFailed, &last);
assert_eq!(tp, None);
assert_eq!(rf, Some(vec!["check".to_string(), "test".to_string()]));
}
#[test]
fn rerun_params_failed_with_no_prior_failures_is_none() {
let (tp, rf) = App::rerun_params(&BrowseAction::RerunFailed, &None);
assert_eq!(tp, None);
assert_eq!(rf, None);
}
#[test]
fn rerun_params_cursor_targets_single_named_step() {
let (tp, rf) = App::rerun_params(&BrowseAction::RerunCursor("clippy".into()), &None);
assert_eq!(tp, None);
assert_eq!(rf, Some(vec!["clippy".to_string()]));
}
#[test]
fn on_file_change_returns_relative_paths_and_drains_queue() {
let root = PathBuf::from("/proj");
let app = mk_app(root.clone());
let (tx, mut rx) = tokio::sync::mpsc::channel::<watcher::WatchEvent>(8);
tx.try_send(vec![root.join("other.rs")]).unwrap();
let mut display = PlainDisplay::new(Theme::new(false), Verbosity::Normal);
let rel = app.on_file_change(&[root.join("src/foo.rs")], &mut rx, &mut display);
assert_eq!(rel, vec![PathBuf::from("src/foo.rs")]);
assert!(
rx.try_recv().is_err(),
"queued events should be fully drained"
);
}
#[test]
fn warn_loop_lines_no_paths_uses_the_none_recorded_branch() {
let lines = warn_loop_lines(&[]);
assert!(
lines
.iter()
.any(|l| l.contains("no triggering paths recorded")),
"expected the empty-paths branch; got {lines:#?}"
);
assert!(
!lines.iter().any(|l| l.contains("triggered by:")),
"must not print a path list when there are none"
);
}
#[test]
fn warn_loop_lines_lists_each_path_when_ten_or_fewer() {
let paths: Vec<PathBuf> = (0..3)
.map(|i| PathBuf::from(format!("src/f{i}.rs")))
.collect();
let lines = warn_loop_lines(&paths);
assert!(
lines
.iter()
.any(|l| l.contains("latest restart triggered by:"))
);
for i in 0..3 {
assert!(
lines.iter().any(|l| l.ends_with(&format!("src/f{i}.rs"))),
"missing path f{i}; got {lines:#?}"
);
}
assert!(
!lines.iter().any(|l| l.contains("more")),
"no overflow line expected for ≤10 paths"
);
}
#[test]
fn warn_loop_lines_truncates_to_ten_with_overflow_count() {
let paths: Vec<PathBuf> = (0..12).map(|i| PathBuf::from(format!("f{i}.rs"))).collect();
let lines = warn_loop_lines(&paths);
let path_lines = lines.iter().filter(|l| l.contains(".rs")).count();
assert_eq!(path_lines, 10, "should list exactly the first 10 paths");
assert!(
lines.iter().any(|l| l.contains("… and 2 more")),
"expected overflow count of 2; got {lines:#?}"
);
}
}