#![forbid(unsafe_code)]
pub mod config;
pub mod git;
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;
use tokio::task::JoinHandle;
use crate::output::style::{Theme, should_color};
use crate::output::{
BrowseAction, Display, DisplayConfig, JsonDisplay, OutputFormat, PlainDisplay, TtyDisplay,
};
use crate::pipeline::StepResult;
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?;
write_run_log(&self.root, &results);
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
};
if dc.verbosity == output::Verbosity::Debug {
eprintln!("[debug] 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;
'main: loop {
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?;
write_run_log(&self.root, &results);
last_failed_steps = Some(
results
.iter()
.filter(|r| !r.success)
.map(|r| r.name.clone())
.collect(),
);
rerun_filter = None;
if let Some(h) = hook_handle.take() {
h.abort();
display.hook_finished();
}
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) => {
while rx.try_recv().is_ok() {}
if dc.verbosity == output::Verbosity::Debug {
eprintln!("[debug] file change — restarting pipeline");
for p in &paths {
eprintln!("[debug] triggered by: {}", p.display());
}
}
if let Some(h) = hook_handle.take() {
h.abort();
display.hook_finished();
}
let rel = rel_paths(&paths, &self.root);
display.run_cancelled();
display.set_trigger(&rel);
trigger_paths = Some(rel);
continue;
}
RunOutcome::Shutdown => {
if let Some(h) = hook_handle.take() {
h.abort();
display.hook_finished();
}
return self.shutdown();
}
RunOutcome::WatcherDied => {
eprintln!("baraddur: file watcher stopped unexpectedly. exiting.");
return Ok(());
}
}
if dc.verbosity == output::Verbosity::Debug {
eprintln!("[debug] 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 => {
if let Some(h) = hook_handle.take() { h.abort(); display.hook_finished(); }
display.exit_browse_mode();
return self.shutdown();
}
maybe = rx.recv() => {
if let Some(h) = hook_handle.take() { h.abort(); display.hook_finished(); }
display.exit_browse_mode();
match maybe {
Some(paths) => {
while rx.try_recv().is_ok() {}
if dc.verbosity == output::Verbosity::Debug {
eprintln!("[debug] file change — triggering pipeline");
for p in &paths {
eprintln!("[debug] triggered by: {}", p.display());
}
}
let rel = rel_paths(&paths, &self.root);
display.set_trigger(&rel);
trigger_paths = Some(rel);
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 => {
if let Some(h) = hook_handle.take() { h.abort(); display.hook_finished(); }
display.exit_browse_mode();
return self.shutdown();
}
BrowseAction::Rerun => {
if let Some(h) = hook_handle.take() { h.abort(); display.hook_finished(); }
display.exit_browse_mode();
trigger_paths = None;
rerun_filter = None;
continue 'main;
}
BrowseAction::RerunFailed => {
if let Some(h) = hook_handle.take() { h.abort(); display.hook_finished(); }
display.exit_browse_mode();
trigger_paths = None;
rerun_filter = last_failed_steps.clone();
continue 'main;
}
BrowseAction::RerunCursor(name) => {
if let Some(h) = hook_handle.take() { h.abort(); display.hook_finished(); }
display.exit_browse_mode();
trigger_paths = None;
rerun_filter = Some(vec![name]);
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;
match res {
Ok(Ok(Some(text))) => display.hook_output(&text),
_ => display.hook_finished(),
}
}
}
}
}
'idle: loop {
tokio::select! {
biased;
_ = &mut stop => {
if let Some(h) = hook_handle.take() { h.abort(); }
return self.shutdown();
}
maybe = rx.recv() => {
if let Some(h) = hook_handle.take() { h.abort(); }
match maybe {
Some(paths) => {
while rx.try_recv().is_ok() {}
if dc.verbosity == output::Verbosity::Debug {
eprintln!("[debug] file change — triggering pipeline");
for p in &paths {
eprintln!("[debug] triggered by: {}", p.display());
}
}
let rel = rel_paths(&paths, &self.root);
display.set_trigger(&rel);
trigger_paths = Some(rel);
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;
match res {
Ok(Ok(Some(text))) => display.hook_output(&text),
_ => display.hook_finished(),
}
}
}
}
}
}
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 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]) {
let log_dir = root.join(".baraddur");
if std::fs::create_dir_all(&log_dir).is_err() {
return;
}
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 _ = std::fs::write(log_dir.join("last-run.log"), &content);
}
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"));
}
}